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,'`); +const testScript=read('wordpress-plugins/calorietoken-heading-repair/assets/site-testnet.js'); +const focusScript=read('wordpress-plugins/calorietoken-heading-repair/assets/app-focus.js'); +writeFileSync(out+'/test.html',common+`
Controlevoorbeeld · alleen fictieve testgegevens · geen netwerkverzoeken

Testaccount instellen

Oude lange WordPress-introductie

Oude melding

`); +writeFileSync(out+'/index.html',`Controle CalorieApp accounthulp

CalorieApp · accounthulp

Controlevoorbeeld van de aangepaste appcode. Accountaanmaak, aanmelden en gegevensoverdracht zijn gesimuleerd. Er worden geen echte accounts gemaakt.

`); +console.log(out); diff --git a/tools/build_food_illustrations.py b/tools/build_food_illustrations.py new file mode 100644 index 00000000..67348aa8 --- /dev/null +++ b/tools/build_food_illustrations.py @@ -0,0 +1,48 @@ +"""Build CalorieApp's original, small local SVG food illustrations (no network).""" +from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] / 'frontend/public/images/food-illustrations' +ROOT.mkdir(parents=True, exist_ok=True) +SHAPES = { +'fruit': '', +'herbs': '', +'sweets': '', +'apple': '', +'banana': '', +'pear': '', +'citrus': '', +'berries': '', +'melon': '', +'grapes': '', +'pineapple': '', +'avocado': '', +'carrot': '', +'potato': '', +'tomato': '', +'vegetables': '', +'bread': '', +'grain': '', +'pasta': '', +'rice': '', +'milk': '', +'yogurt': '', +'cheese': '', +'egg': '', +'fish': '', +'poultry': '', +'meat': '', +'beans': '', +'nuts': '', +'chocolate': '', +'biscuit': '', +'cake': '', +'drink': '', +'coffee': '', +'tea': '', +'oil': '', +'soup': '', +'meal': '', +} +for name, shapes in SHAPES.items(): + svg = f'{shapes}\n' + (ROOT / f'{name}.svg').write_text(svg) +print(f'{len(SHAPES)} original local SVG food illustrations') diff --git a/tools/build_heading_repair_release.py b/tools/build_heading_repair_release.py new file mode 100644 index 00000000..99fea01c --- /dev/null +++ b/tools/build_heading_repair_release.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Build and byte-verify the reversible Heading Repair companion ZIP.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import zipfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SLUG = "calorietoken-heading-repair" +PLUGIN = ROOT / "wordpress-plugins" / SLUG +FILES = ( + "README.txt", + "assets/age-experience.js", + "assets/app-focus.css", + "assets/app-focus.js", + "assets/caloriehelp-mascot-v2.png", + "assets/content-style.css", + "assets/content-style.js", + "assets/heading-repair.css", + "assets/help-label-bootstrap.js", + "assets/help-link-labels.json", + "assets/help-topic-additions.json", + "assets/help.js", + "assets/language-bootstrap.js", + "assets/nutrition-summary.js", + "assets/presentation.js", + "assets/site-app-integration.js", + "assets/site-blog-timeline.js", + "assets/site-discovery.js", + "assets/site-menu-pages.js", + "assets/site-ready-languages.js", + "assets/site-session-repair.js", + "assets/site-testnet.js", + "assets/site-tokenomics.js", + "calorietoken-heading-repair.php", + "index.php", +) +FIXED_TIME = (2021, 9, 16, 0, 0, 0) + + +def release_version(plugin: Path) -> str: + source = (plugin / f"{SLUG}.php").read_text(encoding="utf-8") + header = re.search(r"^\s*\* Version: (\d+\.\d+\.\d+)\s*$", source, re.M) + constant = re.search(r"const VERSION = '(\d+\.\d+\.\d+)';", source) + if not header or not constant or header[1] != constant[1]: + raise ValueError("Plugin header and runtime version must match") + version = header[1] + notes = (plugin / "README.txt").read_text(encoding="utf-8") + if not notes.startswith(f"CalorieToken Heading and Language Repair {version}\n"): + raise ValueError("README must name the current release") + return version + + +def build(output_dir: Path, plugin: Path = PLUGIN) -> dict: + paths = list(plugin.rglob("*")) + if plugin.is_symlink() or any(path.is_symlink() for path in paths): + raise ValueError("Release paths cannot be symlinks") + actual = {path.relative_to(plugin).as_posix() for path in paths if path.is_file()} + if actual != set(FILES): + raise ValueError("Release inventory changed; review missing/unexpected paths before packaging") + + version = release_version(plugin) + output_dir.mkdir(parents=True, exist_ok=True) + archive = output_dir / f"{SLUG}-{version}.zip" + source = {name: (plugin / name).read_bytes() for name in FILES} + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as target: + for name, data in source.items(): + entry = zipfile.ZipInfo(f"{SLUG}/{name}", FIXED_TIME) + entry.compress_type = zipfile.ZIP_DEFLATED + entry.external_attr = 0o100644 << 16 + target.writestr(entry, data) + + with zipfile.ZipFile(archive) as packaged: + expected = {f"{SLUG}/{name}" for name in source} + if packaged.testzip() or set(packaged.namelist()) != expected: + raise ValueError("Invalid release archive") + for name, data in source.items(): + if packaged.read(f"{SLUG}/{name}") != data: + raise ValueError(f"Packaged source mismatch: {name}") + + report = { + "schema": "calorietoken.heading-repair-release.v1", + "version": version, + "archive": archive.name, + "files": len(source), + "bytes": archive.stat().st_size, + "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "source_files": { + name: hashlib.sha256(data).hexdigest() for name, data in source.items() + }, + } + manifest = output_dir / f"{SLUG}-{version}.manifest.json" + manifest.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + return report + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, default=ROOT / "dist") + arguments = parser.parse_args() + print(json.dumps(build(arguments.output_dir), indent=2)) diff --git a/tools/build_login_repair.py b/tools/build_login_repair.py new file mode 100644 index 00000000..388206e7 --- /dev/null +++ b/tools/build_login_repair.py @@ -0,0 +1,43 @@ +"""Build the guarded one-file repair, never the older complete Bridge.""" + +import argparse +import hashlib +from pathlib import Path +import re +import subprocess +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +BASE = "d973d7e0ff04f90cd7592d34092ae25a3b7963b1" +REST_PATH = "wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-rest.php" +PLUGIN = ROOT / "wordpress-plugins/calorieapp-login-repair" + + +def build(output: Path) -> None: + installer = (PLUGIN / "calorieapp-login-repair.php").read_bytes() + before = subprocess.check_output(["git", "show", f"{BASE}:{REST_PATH}"], cwd=ROOT) + after = (ROOT / REST_PATH).read_bytes() + for label, content in (("BEFORE", before), ("AFTER", after)): + expected = re.search(rf"{label}_SHA256 = '([a-f0-9]{{64}})'", installer.decode()).group(1) + if hashlib.sha256(content).hexdigest() != expected: + raise SystemExit(f"{label} payload differs from the reviewed installer hash") + entries = { + "calorieapp-login-repair.php": installer, + "README.md": (PLUGIN / "README.md").read_bytes(), + "payload/before.php": before, + "payload/after.php": after, + } + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in entries.items(): + info = zipfile.ZipInfo("calorieapp-login-repair/" + name, (2026, 9, 13, 0, 0, 0)) + info.external_attr = 0o100644 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, content) + print(f"{output}: {output.stat().st_size} bytes; SHA-256 {hashlib.sha256(output.read_bytes()).hexdigest()}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + build(parser.parse_args().output.resolve()) diff --git a/tools/build_site_style_release.py b/tools/build_site_style_release.py new file mode 100644 index 00000000..578ad776 --- /dev/null +++ b/tools/build_site_style_release.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Build a repeatable Site Style ZIP and verify every packaged source byte.""" +from __future__ import annotations +import argparse +import hashlib +import json +import re +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SLUG = "calorietoken-site-style" +PLUGIN = ROOT / "wordpress-plugins" / SLUG +FILES = ( + 'LEESMIJ.md', + 'LICENSE', + 'assets/app-information.css', + 'assets/app-information.js', + 'assets/app-integration.js', + 'assets/blog-timeline.js', + 'assets/calorieapp-logo.svg', + 'assets/content-data.json', + 'assets/content-language.js', + 'assets/discovery-data.json', + 'assets/discovery.css', + 'assets/discovery.js', + 'assets/display-language-runtime.js', + 'assets/fonts/OFL.txt', + 'assets/fonts/knewave-latin-400-normal.woff2', + 'assets/fonts/knewave-latin-ext-400-normal.woff2', + 'assets/help-data.json', + 'assets/help.js', + 'assets/menu-data.json', + 'assets/menu-pages.css', + 'assets/menu-pages.js', + 'assets/navigation.js', + 'assets/presentation-data.json', + 'assets/presentation.css', + 'assets/presentation.js', + 'assets/ready-languages.js', + 'assets/refinements.css', + 'assets/refinements.js', + 'assets/style.css', + 'assets/style.js', + 'assets/testnet-data.json', + 'assets/testnet.js', + 'assets/tokenomics.js', + 'calorietoken-site-style.php', + 'content/community.html', + 'content/privacy.html', + 'content/terms.html', + 'public-pages.php', + 'public-template.php', + 'review.php', + 'templates.php', +) +FIXED_TIME = (2021, 9, 16, 0, 0, 0) + + +def release_version(plugin: Path) -> str: + source = (plugin / (SLUG + ".php")).read_text(encoding="utf-8") + header = re.search(r"^\s*\* Version: (\d+\.\d+\.\d+)\s*$", source, re.M) + constant = re.search(r"const VERSION = '(\d+\.\d+\.\d+)';", source) + if not header or not constant or header[1] != constant[1]: + raise ValueError("Plugin header and runtime version must match") + version = header[1] + notes = (plugin / "LEESMIJ.md").read_text(encoding="utf-8") + if not notes.startswith("# CalorieToken Site Style " + version + "\n") or SLUG + "-" + version + ".zip" not in notes: + raise ValueError("Installation notes must name the current release") + return version + + +def build(output_dir: Path, plugin: Path = PLUGIN) -> dict: + paths = list(plugin.rglob("*")) + if plugin.is_symlink() or any(p.is_symlink() for p in paths): + raise ValueError("Release paths cannot be symlinks") + actual = {p.relative_to(plugin).as_posix() for p in paths if p.is_file()} + if actual != set(FILES): + raise ValueError("Release inventory changed; review missing/unexpected paths before packaging") + version = release_version(plugin) + output_dir.mkdir(parents=True, exist_ok=True) + archive = output_dir / f"{SLUG}-{version}.zip" + source = {name: (plugin / name).read_bytes() for name in FILES} + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as z: + for name, data in source.items(): + entry = zipfile.ZipInfo(f"{SLUG}/{name}", FIXED_TIME) + entry.compress_type = zipfile.ZIP_DEFLATED + entry.external_attr = 0o100644 << 16 + z.writestr(entry, data) + with zipfile.ZipFile(archive) as z: + if z.testzip() or set(z.namelist()) != {f"{SLUG}/{name}" for name in source}: + raise ValueError("Invalid release archive") + for name, data in source.items(): + if z.read(f"{SLUG}/{name}") != data: + raise ValueError("Packaged source mismatch: " + name) + report = {"schema": "calorietoken.site-style-release.v1", "version": version, + "archive": archive.name, "files": len(source), "bytes": archive.stat().st_size, + "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "source_files": {name: hashlib.sha256(data).hexdigest() for name, data in source.items()}} + (output_dir / f"{SLUG}-{version}.manifest.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + return report + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, default=ROOT / "dist") + args = parser.parse_args() + print(json.dumps(build(args.output_dir), indent=2)) diff --git a/tools/build_total_review_package.py b/tools/build_total_review_package.py new file mode 100644 index 00000000..65fdb62e --- /dev/null +++ b/tools/build_total_review_package.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +"""Build the total step 1-5 review without changing or authorizing R3 content.""" +from __future__ import annotations + +import argparse +import hashlib +import html +import json +import re +import shutil +import zipfile +from pathlib import Path +from typing import Any + + +PACKAGE_NAME = "CalorieToken_Totaalreview_2026-09-16" +FIXED_TIME = (2026, 9, 16, 0, 0, 0) +UPLOAD_FILE_LIMIT = 512 * 1024 * 1024 +UPLOAD_PART_TARGET = 160 * 1024 * 1024 +MEDIA_SUFFIXES = {".mp4", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".vtt", ".srt"} +SOURCE_EXCLUDED_PARTS = { + "node_modules", ".next", "dist", "__pycache__", ".pytest_cache", + ".venv", "ux-check-evidence", ".git", +} +SOURCE_EXCLUDED_NAMES = {"tsconfig.tsbuildinfo"} +SOURCE_EXCLUDED_SUFFIXES = {".pyc", ".db", ".sqlite", ".sqlite3"} + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def review_data(review_html: Path) -> dict[str, Any]: + source = review_html.read_text(encoding="utf-8") + match = re.search( + r'', + source, + re.S, + ) + if not match: + raise ValueError("R3 review-data payload is missing") + data = json.loads(match.group(1)) + if len(data.get("items", [])) != 97 or len(data.get("readiness", [])) != 97: + raise ValueError("R3 proposal/readiness inventory is not exactly 97") + return data + + +def referenced_files(data: Any) -> list[Path]: + values: set[Path] = set() + + def visit(value: Any) -> None: + if isinstance(value, str) and Path(value).suffix.lower() in MEDIA_SUFFIXES: + candidate = Path(value) + if candidate.is_absolute() or ".." in candidate.parts: + raise ValueError(f"Unsafe review path: {value}") + values.add(candidate) + elif isinstance(value, dict): + for nested in value.values(): + visit(nested) + elif isinstance(value, list): + for nested in value: + visit(nested) + + visit(data) + return sorted(values, key=lambda path: path.as_posix()) + + +def copy_file(source: Path, target: Path) -> None: + if not source.is_file() or source.is_symlink(): + raise ValueError(f"Required regular file is missing: {source}") + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + + +def source_inventory(source_root: Path) -> list[Path]: + files = [] + for path in source_root.rglob("*"): + relative = path.relative_to(source_root) + if any(part in SOURCE_EXCLUDED_PARTS for part in relative.parts): + continue + if path.is_symlink(): + raise ValueError(f"Unexpected source symlink: {relative}") + if ( + path.is_file() + and path.name not in SOURCE_EXCLUDED_NAMES + and path.suffix.lower() not in SOURCE_EXCLUDED_SUFFIXES + ): + files.append(relative) + return sorted(files, key=lambda path: path.as_posix()) + + +def write_zip(archive: Path, entries: list[tuple[Path, str]]) -> None: + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9, allowZip64=True) as target: + for source, name in entries: + info = zipfile.ZipInfo(name, FIXED_TIME) + info.external_attr = 0o100644 << 16 + # PNG/GIF still compress materially inside this review set; MP4/JPEG/WebP/ZIP + # are already compressed and are stored to keep the build fast and stable. + info.compress_type = zipfile.ZIP_STORED if source.suffix.lower() in { + ".mp4", ".jpg", ".jpeg", ".webp", ".zip" + } else zipfile.ZIP_DEFLATED + with source.open("rb") as stream, target.open(info, "w", force_zip64=True) as output: + shutil.copyfileobj(stream, output, 1024 * 1024) + with zipfile.ZipFile(archive) as result: + if result.testzip() is not None or len(result.namelist()) != len(entries): + raise ValueError(f"Invalid ZIP: {archive}") + + +def build_source_zip(source_root: Path, destination: Path) -> dict[str, Any]: + inventory = source_inventory(source_root) + entries = [(source_root / relative, f"CalorieApp-candidate/{relative.as_posix()}") for relative in inventory] + write_zip(destination, entries) + return { + "archive": destination.name, + "files": len(inventory), + "bytes": destination.stat().st_size, + "sha256": sha256(destination), + "excluded": sorted( + SOURCE_EXCLUDED_PARTS + | SOURCE_EXCLUDED_NAMES + | {f"*{suffix}" for suffix in SOURCE_EXCLUDED_SUFFIXES} + ), + } + + +def start_html(copy: dict[str, Any], source_report: dict[str, Any], plugin_hash: str) -> str: + locale_options = [ + ("en", "English"), ("nl", "Nederlands"), ("zh-Hans", "简体中文"), + ("hi", "हिन्दी"), ("es", "Español"), ("ar", "العربية"), + ("fr", "Français"), ("bn", "বাংলা"), ("pt", "Português"), + ("id", "Bahasa Indonesia"), ("ur", "اردو"), + ] + options = "".join( + f'' + for tag, label in locale_options + ) + translations = json.dumps(copy, ensure_ascii=False, separators=(",", ":")).replace(" + +CalorieToken totaalreview 16 september 2026 + +
CalorieToken-logo

Totale werkreview · 16 september 2026

Stap 1–5 bij elkaar

Aiming to be the world's food token.

+
+

Uitkomst

De lokale kandidaat en de complete review zijn afgerond; productie is bewust niet gewijzigd. Stap 1 en 2 bevatten alle eerdere wensen plus alternatief beeld en de correcte OFF/USDA-indeling. Stap 3–5 zijn daarop aangesloten. Externe upload, CI, deployment en publicatie blijven achter één totale GO.

303/303 NodeTypeScript + productie-build geslaagd97 voorstellen behoudenNiet live · geen publicatie-goedkeuring
+ +
+
Stap 1 · afgeronde kandidaat

App en dagboek

OFF-producten, USDA-basisvoeding, porties, periodeoverzicht, logoutveiligheid, alternatief beeld en één correcte bron-/scoreweergave.

+
Stap 2 · afgeronde kandidaat

Xaman en WordPress

Minimale geaggregeerde weergave in de bestaande kaart, buiten het iframe; geen individuele voedings- of accountgegevens.

+
Stap 3 · aangesloten

Website en Help

Bestaande CalorieHelp en vormgeving blijven staan; elf talen krijgen dezelfde uitleg over bron, foto en score.

+
Stap 4 · compleet bewaard

Contentreview

97 voorstellen en 214 gebruikte media-/ondertitelpaden. 38 zonder en 59 met open controles; niets automatisch ingehaald.

+
Stap 5 · gereed voor besluit

Release en rollback

Bronkandidaat, deterministische plugin, tests, CI-poorten, uitrolvolgorde en rollback liggen vast. Eén totale GO ontbreekt nog.

+
+ +

5
1
1

A2
B1
C0
D0
E1

Voorbeeld: 5 OFF-producten, waarvan 4 met bronletter en 1 zonder; de USDA-registratie staat apart en is dus niet ‘ontbrekend’.

Alternatief beeld

OFF-alternatief
OFF
USDA-alternatief
USDA
Overig alternatief

Deze neutrale lokale illustraties verschijnen alleen als een echte bronfoto ontbreekt, wordt afgewezen of niet laadt.

+ +

Bewijs en eerlijke open poorten

Lokaal gereed303 Node-tests, TypeScript, Next-productiebouw, Python-syntaxis, 5 releasebuildertests en strikt WordPress-DOM-contract.
Na totale GO via CIBackend pytest met vastgezette dependencies, native PHP-lint en beide Chromiumtests in 11 talen op 360/412/1440.
Na deploymentEchte Xaman login/logout, live WordPress-kaart, fysieke mobiele controle, build-ID en rollbackcontrole.
Niet gebeurdGeen GitHub-update, branch/PR-mutatie, deployment, scheduling of publicatie.
+ +

Pakketidentiteit

Appbasis ac724aabf1534e51e819ef5d84df04f246eeea23 · tree 737afcaf25b6e0ccbb97e8687143122e3bea0dd8.

Plugin SHA-256 {plugin_hash}.

Bronpakket: {source_report['files']} bestanden · SHA-256 {source_report['sha256']}.

Open controles en historische status uit R3 zijn ongewijzigd behouden. Dit scherm is een reviewkandidaat, geen live- of publicatieclaim.

+
+""" + + +def content_manifest(root: Path) -> list[dict[str, Any]]: + result = [] + for path in sorted((item for item in root.rglob("*") if item.is_file()), key=lambda item: item.relative_to(root).as_posix()): + relative = path.relative_to(root).as_posix() + if relative == "BESTANDSCONTROLE_R4.json": + continue + result.append({"path": relative, "bytes": path.stat().st_size, "sha256": sha256(path)}) + return result + + +def build(arguments: argparse.Namespace) -> dict[str, Any]: + output_parent = arguments.output_dir.resolve() + package = output_parent / PACKAGE_NAME + archive = output_parent / f"{PACKAGE_NAME}.zip" + core_archive = output_parent / f"{PACKAGE_NAME}_DEEL-A.zip" + report_path = output_parent / f"{PACKAGE_NAME}.manifest.json" + for target in (package, archive, core_archive, report_path): + if target.exists(): + raise ValueError(f"Refusing to overwrite existing output: {target}") + output_parent.mkdir(parents=True, exist_ok=True) + package.mkdir() + + data = review_data(arguments.review_html) + media = referenced_files(data) + for relative in media: + copy_file(arguments.review_media_root / relative, package / relative) + + copy_file(arguments.review_html, package / "VOLLEDIGE_REVIEW_R3.html") + copy_file(arguments.r3_root / "CalorieToken_Nieuwe_Review_R3.html", package / "VOLLEDIGE_REVIEW_R3_STANDALONE.html") + for name in [ + "HERVAT_HIER_R3.md", "feature-campaign-11-locales.json", + "publication-readiness.json", "run-status-R3.json", + "BESTANDSCONTROLE_REVIEW_R3.json", "BESTANDSCONTROLE_BRON_R3.json", + ]: + copy_file(arguments.r3_root / name, package / "r3-status" / name) + + candidate = package / "candidate" + candidate.mkdir() + copy_file( + arguments.source_root / "docs/release-review/TOTAL-CANDIDATE-STATUS-2026-09-16.md", + candidate / "TOTAL-CANDIDATE-STATUS-2026-09-16.md", + ) + copy_file( + arguments.source_root / "docs/release-review/total-candidate-status-2026-09-16.json", + candidate / "total-candidate-status-2026-09-16.json", + ) + copy_file( + arguments.source_root / "docs/release-review/STEPS-1-5-UX-NUTRITION-2026-09-16.md", + candidate / "STEPS-1-5-UX-NUTRITION-2026-09-16.md", + ) + for name in ["food-placeholder-off.svg", "food-placeholder-usda.svg", "food-placeholder-other.svg"]: + copy_file(arguments.source_root / "frontend/public/images" / name, candidate / "images" / name) + copy_file(arguments.plugin_zip, candidate / arguments.plugin_zip.name) + copy_file(arguments.plugin_manifest, candidate / arguments.plugin_manifest.name) + + source_archive = candidate / "calorietoken-source-candidate-2026-09-16.zip" + source_report = build_source_zip(arguments.source_root, source_archive) + (candidate / "source-candidate.manifest.json").write_text( + json.dumps(source_report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + copy = json.loads((arguments.source_root / "frontend/config/food-source-copy.json").read_text(encoding="utf-8")) + plugin_hash = sha256(arguments.plugin_zip) + if plugin_hash != "5c7736741570bafd1247ef4a42d855d682e9bcbe352ef13d9d668c0ccbe6f5e5": + raise ValueError("Unexpected Heading Repair candidate hash") + (package / "START_HIER.html").write_text(start_html(copy, source_report, plugin_hash), encoding="utf-8") + (package / "LEES_EERST.md").write_text( + "# CalorieToken totaalreview — 16 september 2026\n\n" + "Open `START_HIER.html`. Van daaruit opent de volledige bewaarde R3-review " + "met 97 voorstellen en alle gebruikte lokale media.\n\n" + "Status: lokale kandidaat afgerond; niets gepubliceerd, gepland, gedeployed " + "of extern geüpload. Eén expliciete totale GO blijft vereist.\n", + encoding="utf-8", + ) + (package / "PAKKETDELEN_LEES_EERST.md").write_text( + "# Verliesvrije pakketdelen\n\n" + "De volledige ZIP is groter dan de opslaglimiet per bestand. Pak daarom " + "`DEEL-A` en `DEEL-B-LANGE-FILMS` uit in **dezelfde lege map**. Beide ZIPs " + "gebruiken dezelfde hoofdmap en bevatten geen conflicterende inhoud; samen " + "vormen ze exact de volledige totaalreview. Open daarna `START_HIER.html`.\n\n" + "De SHA-256-hashes en bestandstelling staan in het release-manifest. Het " + "opsplitsen wijzigt geen media, voorstel, status of toestemming.\n", + encoding="utf-8", + ) + + manifest = content_manifest(package) + inventory = { + "schema": "calorietoken.total-review-package.v1", + "date": "2026-09-16", + "proposals": len(data["items"]), + "readiness_records": len(data["readiness"]), + "referenced_media_and_subtitle_paths": len(media), + "referenced_bytes": sum((arguments.review_media_root / relative).stat().st_size for relative in media), + "publication_authorized": bool(data.get("publication_authorized")), + "scheduling_authorized": bool(data.get("scheduling_authorized")), + "source_candidate": source_report, + "heading_repair_sha256": plugin_hash, + "files": manifest, + } + (package / "BESTANDSCONTROLE_R4.json").write_text( + json.dumps(inventory, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + entries = [ + (path, f"{PACKAGE_NAME}/{path.relative_to(package).as_posix()}") + for path in sorted((item for item in package.rglob("*") if item.is_file()), key=lambda item: item.relative_to(package).as_posix()) + ] + write_zip(archive, entries) + long_film_entries = [ + entry for entry in entries + if entry[0].relative_to(package).parts[:2] == ("creatief6", "lange_films") + ] + part_note = package / "PAKKETDELEN_LEES_EERST.md" + core_entries = [entry for entry in entries if entry not in long_film_entries] + write_zip(core_archive, core_entries) + + # Keep an MP4 and its sidecar subtitles together while producing upload-sized + # volumes. The alphabetical, sequential packing is deterministic. + films_by_stem: dict[str, list[tuple[Path, str]]] = {} + for entry in long_film_entries: + films_by_stem.setdefault(entry[0].stem, []).append(entry) + film_groups: list[list[tuple[Path, str]]] = [] + current_group: list[tuple[Path, str]] = [] + current_bytes = 0 + for stem in sorted(films_by_stem): + stem_entries = films_by_stem[stem] + stem_bytes = sum(entry[0].stat().st_size for entry in stem_entries) + if current_group and current_bytes + stem_bytes > UPLOAD_PART_TARGET: + film_groups.append(current_group) + current_group = [] + current_bytes = 0 + current_group.extend(stem_entries) + current_bytes += stem_bytes + if current_group: + film_groups.append(current_group) + + volume_sources: list[tuple[Path, list[tuple[Path, str]]]] = [(core_archive, core_entries)] + for index, film_group in enumerate(film_groups, start=1): + film_archive = output_parent / f"{PACKAGE_NAME}_DEEL-B{index}-LANGE-FILMS.zip" + if film_archive.exists(): + raise ValueError(f"Refusing to overwrite existing output: {film_archive}") + film_entries = [ + (part_note, f"{PACKAGE_NAME}/PAKKETDELEN_LEES_EERST.md"), + *film_group, + ] + write_zip(film_archive, film_entries) + volume_sources.append((film_archive, film_entries)) + volumes = [ + { + "archive": path.name, + "bytes": path.stat().st_size, + "sha256": sha256(path), + "files": len(volume_entries), + } + for path, volume_entries in volume_sources + ] + if any(volume["bytes"] > UPLOAD_FILE_LIMIT for volume in volumes): + raise ValueError("A persistent-storage package part exceeds the 512 MiB limit") + report = { + "schema": "calorietoken.total-review-release.v1", + "archive": archive.name, + "bytes": archive.stat().st_size, + "sha256": sha256(archive), + "files": len(entries), + "proposals": 97, + "referenced_media_and_subtitle_paths": len(media), + "persistent_storage_parts": volumes, + "production_mutated": False, + "total_go_required": True, + } + report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + return report + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--review-html", type=Path, required=True) + parser.add_argument("--review-media-root", type=Path, required=True) + parser.add_argument("--r3-root", type=Path, required=True) + parser.add_argument("--plugin-zip", type=Path, required=True) + parser.add_argument("--plugin-manifest", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + print(json.dumps(build(parser.parse_args()), indent=2)) diff --git a/tools/build_usda_search_catalog.py b/tools/build_usda_search_catalog.py new file mode 100644 index 00000000..b3dbedb9 --- /dev/null +++ b/tools/build_usda_search_catalog.py @@ -0,0 +1,73 @@ +"""Build a compact, dated public search catalogue from official USDA downloads. + +No API credentials or paid service are needed. Original descriptions, FDC IDs, +units and nutrient precision are retained. Missing or censored values stay null. +The original three-food reference is deliberately not overwritten. +""" +from pathlib import Path +import argparse +import hashlib +import json +import math +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +NUTRIENTS = {1003, 1004, 1005, 1008, 2047, 2048} + + +def build(source: Path, output: Path): + foods, sources, seen = [], [], set() + for filename, edition in [ + ("FoodData_Central_foundation_food_json_2026-04-30.zip", "2026-04-30"), + ("FoodData_Central_sr_legacy_food_json_2018-04.zip", "2018-04"), + ]: + path = source / filename + with zipfile.ZipFile(path) as archive: + member = next(n for n in archive.namelist() if n.endswith(".json")) + records = next(iter(json.loads(archive.read(member)).values())) + valid = 0 + for food in records: + # USDA's Foundation export contains null slots, not food records. + if not isinstance(food, dict): + continue + fdc_id = food["fdcId"] + if fdc_id in seen: + raise ValueError(f"Duplicate FDC identifier: {fdc_id}") + seen.add(fdc_id) + nutrients = [] + for value in food.get("foodNutrients", []): + nutrient = value.get("nutrient", {}) + if nutrient.get("id") not in NUTRIENTS: + continue + amount = value.get("amount") + if isinstance(amount, bool) or not isinstance(amount, (int, float)) or not math.isfinite(amount) or amount < 0: + amount = None + loq = value.get("loq") + if amount == 0 and isinstance(loq, (int, float)) and loq > 0: + amount = None + nutrients.append({"id": nutrient["id"], "name": nutrient["name"], + "unit": nutrient["unitName"], "amount": amount, + "loq": loq}) + foods.append({"fdc_id": fdc_id, "description": food["description"], + "data_type": food["dataType"], + "category": food.get("foodCategory", {}).get("description", ""), + "edition": edition, "nutrients": nutrients}) + valid += 1 + sources.append({"url": "https://fdc.nal.usda.gov/fdc-datasets/" + filename, + "edition": edition, "records": valid, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}) + data = {"version": 1, "retrieved_on": "2026-09-15", "source": "USDA FoodData Central", + "licence": "CC0 1.0", "basis": "100 g edible food", "sources": sources, + "foods": sorted(foods, key=lambda f: f["fdc_id"])} + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":"))) + print(json.dumps({"records": len(foods), "bytes": output.stat().st_size, + "sources": sources}, indent=2)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path) + parser.add_argument("--output", type=Path, default=ROOT / "frontend/public/data/usda-search-foods.json") + args = parser.parse_args() + build(args.source, args.output) diff --git a/tools/tests/account_data_import_ui.test.mjs b/tools/tests/account_data_import_ui.test.mjs index 1d33a5c6..9d745b82 100644 --- a/tools/tests/account_data_import_ui.test.mjs +++ b/tools/tests/account_data_import_ui.test.mjs @@ -183,6 +183,16 @@ test("private import response validation is strict and bounded", async () => { assert.equal(isAccountDataImportResponse(null), false); }); +test("private import exposes a four-part readiness path without weakening confirmations", async () => { + const source = await readFile(COMPONENT_PATH, "utf8"); + assert.match(source, /const readiness = \[fileReady, sourceReady, targetReady, acknowledged\]/); + assert.match(source, /role="progressbar"/); + assert.match(source, /selectedFile\.name/); + assert.match(source, /targetConfirmation === userId/); + assert.match(source, /isAccountDataImportConfirmationReady/); + assert.match(source, /min-h-11 w-full/); +}); + test("private import proxy requires exact same-origin intent", async () => { const { isTrustedAccountImportRequest } = await loadRequestPolicyModule(); const path = "api/identity/import"; diff --git a/tools/tests/account_erasure_ui.test.mjs b/tools/tests/account_erasure_ui.test.mjs index c8873fa3..02ef54fe 100644 --- a/tools/tests/account_erasure_ui.test.mjs +++ b/tools/tests/account_erasure_ui.test.mjs @@ -1,3 +1,4 @@ +import { authUi } from "./helpers/auth_ui.mjs"; import assert from "node:assert/strict"; import { createRequire } from "node:module"; import { readFile } from "node:fs/promises"; @@ -68,6 +69,7 @@ async function loadComponentModule(overrides = {}) { exports: module.exports, module, require(specifier) { + if (specifier === "@/lib/authUi") return authUi; if (specifier === "react") { return overrides.react ?? {}; } @@ -338,13 +340,13 @@ test("account erasure UI is doubly disabled and sends no confirmation elsewhere" assert.equal(envExample.includes("NEXT_PUBLIC_ACCOUNT_ERASURE_UI_ENABLED=false"), true); }); -test("account tools stay available but collapsed below the primary app", async () => { +test("account tools stay available inside the private account view", async () => { const panel = await readFile(PANEL_PATH, "utf8"); - assert.match(panel, /