diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..5a6729d0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,24 @@ +# CalorieApp project continuity + +- Continue the existing checkpoint; never rebuild the CalorieVerse concept from + scratch. Read `docs/CALORIEVERSE_PREVIEW_CHECKPOINT.md` before changing it. +- Draft PR #150, branch `feat/participation-node-simulator`, continues from + `f8711ee1a7d3c6e43c300fc6b1123ebe0329ad44`. No merge or deployment is authorized + for this work. Keep the PR a draft; do not change live services or WordPress. +- `/gameverse/preview` is the small meadow review route. `/gameverse` retains + the larger existing draft with Studio, identity and participation integrations. + The extra route is a review boundary, not a second world or product version. +- CalorieVerse grows continuously: preserve the original world/starter IDs, + module IDs, storage keys and earned progress. Never require a player reset. +- Reuse the eleven-language display registry. Keep UI copy out of reducers. +- Content packs, pure activity reducers, rendering and persistence are separate. + Unknown module fields must survive; unsupported formats must not be overwritten. +- Ordinary play requires no account, wallet, storage contribution, compute role + or reward. Participation is separately opt-in, capped and reversible, with + independent storage/compute consent and pause/resume/full-stop controls. +- Local guest progress is untrusted and cannot authorize calT/CAL/XRP rewards. + Synthetic simulators are not an active network or settlement system. +- Never place private food logs, identity, credentials or age records in public + participant data. Preserve the existing private PostgreSQL boundary. +- Keep deferred architecture, publication and governance work in repository docs; + never store secrets or temporary chat credentials here. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd2ca8bd..7ae7334a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,7 @@ jobs: python tools/sync_identity_contracts.py --check python -m unittest tools.tests.test_identity_contracts python -m unittest tools.tests.test_localization_contracts + python -m unittest tools.tests.test_build_total_review_package - name: Test mobile, offline custody and tracked-secret guards run: | @@ -63,6 +64,17 @@ jobs: python -m unittest tools.tests.test_offline_age_custody python -m unittest tools.tests.test_tracked_secret_patterns + - name: Test local synthetic participation simulators + run: | + python -m unittest tools.tests.test_participation_simulator + python -m unittest tools.tests.test_participation_compute_simulator + python -m unittest tools.tests.test_participation_ui_adapter + python -m unittest tools.tests.test_participation_growth_simulator + python -m unittest tools.tests.test_participation_reliability_simulator + python -m unittest tools.tests.test_participation_data_release_simulator + python -m unittest tools.tests.test_participation_resource_policy + python -m unittest tools.tests.test_gameverse_character_identity + wordpress-plugin-release-check: name: WordPress plugin release check runs-on: ubuntu-latest @@ -76,7 +88,10 @@ jobs: run: find wordpress-plugins -type f -name '*.php' -print0 | xargs -0 -n1 php -l - name: Validate standalone Site Style package - run: node --test tools/tests/wordpress_site_style_package.test.mjs tools/tests/wordpress_app_origin.test.mjs + run: node --test tools/tests/wordpress_site_style_package.test.mjs tools/tests/wordpress_app_origin.test.mjs tools/tests/wordpress_cookie_controls.test.mjs + + - name: Test read-only Site Style review inventory + run: php tools/tests/wordpress_site_style_review.test.php - name: Test legal footer compatibility shell: bash @@ -134,6 +149,11 @@ jobs: - name: Build and inspect release archive run: python tools/build_wordpress_plugin_release.py + - name: Build and verify Site Style release + run: | + python -m unittest tools.tests.test_build_site_style_release + python tools/build_site_style_release.py + backend-tests: name: Backend tests (Python 3.11) runs-on: ubuntu-latest @@ -304,34 +324,21 @@ jobs: working-directory: frontend run: npm ci - - name: Audit frontend production dependencies + - name: Audit frontend runtime and development dependencies working-directory: frontend - run: npm audit --omit=dev --audit-level=critical + run: npm audit --audit-level=high - name: Lint frontend working-directory: frontend run: npm run lint - - name: Test embedded login and private account controls - run: >- - node --test - tools/tests/auth_callback_return.test.mjs - tools/tests/account_data_import_ui.test.mjs - tools/tests/account_data_export_validation.test.mjs - tools/tests/account_erasure_ui.test.mjs - tools/tests/account_privacy_locales.test.mjs - tools/tests/account_privacy_display.test.mjs - tools/tests/display_language_protocol.test.mjs - tools/tests/display_language_ui.test.mjs - tools/tests/testnet_entry.test.mjs - tools/tests/backend_proxy_logout_fallback.test.mjs - tools/tests/backend_warmup_rate_limit.test.mjs - tools/tests/calorieapp_embed_readiness.test.mjs - tools/tests/food_logging_ui.test.mjs - tools/tests/food_search_deadline.test.mjs - tools/tests/identity_locales.test.mjs - tools/tests/xaman_logout_request.test.mjs - tools/tests/xaman_login_start_retry.test.mjs + - name: Test all frontend and WordPress user flows + # Discover new test files automatically so follow-up checks cannot be + # forgotten in a manually maintained list. + run: node --test tools/tests/*.test.mjs + + - name: Test participation control state machine + run: node --test tools/tests/participation_lab_state.test.mjs - name: Build frontend working-directory: frontend diff --git a/.github/workflows/food-ux-isolated-check.yml b/.github/workflows/food-ux-isolated-check.yml new file mode 100644 index 00000000..12e26298 --- /dev/null +++ b/.github/workflows/food-ux-isolated-check.yml @@ -0,0 +1,124 @@ +name: Food UX isolated check + +on: + push: + branches: [repair/food-ux-integration-20260915] + pull_request: + branches: [herstel/vervolg-20260915] + paths: + - 'frontend/**' + - 'backend/app/**' + - 'backend/tests/**' + - 'wordpress-plugins/calorieapp-account-profile/**' + - 'backend/app/schemas.py' + - 'backend/app/services/open_food_facts.py' + - 'backend/tests/test_food_log_view.py' + - 'backend/tests/test_open_food_facts_normalization.py' + - 'tools/tests/**' + - 'tools/build_heading_repair_release.py' + - 'wordpress-plugins/calorietoken-heading-repair/**' + - '.github/workflows/food-ux-isolated-check.yml' + +permissions: + contents: read + +concurrency: + group: food-ux-isolated-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 12 + env: + NEXT_TELEMETRY_DISABLED: '1' + CI: 'true' + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Record the source under test + run: | + mkdir -p ux-check-evidence + git rev-parse HEAD > ux-check-evidence/verified-commit.txt + git rev-parse 'HEAD^{tree}' > ux-check-evidence/verified-tree.txt + git archive --format=tar.gz -o ux-check-evidence/verified-source.tar.gz HEAD + - name: Install locked dependencies and test tools + run: | + npm ci --prefix frontend --no-audit --no-fund + python -m pip install -r backend/requirements.txt 'playwright==1.57.0' + python -m playwright install --with-deps chromium + - name: Full regression suite and production build + shell: bash + run: | + set -euo pipefail + node --test tools/tests/*.test.mjs 2>&1 | tee ux-check-evidence/regressions.log + PYTHONPATH=backend python -m pytest -q \ + backend/tests/test_account_profile.py \ + backend/tests/test_account_data_export.py \ + backend/tests/test_account_data_import.py \ + backend/tests/test_account_erasure.py \ + backend/tests/test_database.py \ + backend/tests/test_identity_endpoints.py \ + backend/tests/test_inactive_account_erasure_execution.py \ + backend/tests/test_food_log_view.py \ + backend/tests/test_open_food_facts_normalization.py \ + 2>&1 | tee ux-check-evidence/backend-food-tests.log + python -m unittest tools.tests.test_build_heading_repair_release 2>&1 | tee ux-check-evidence/heading-release-tests.log + find wordpress-plugins/calorietoken-heading-repair -type f -name '*.php' -print0 \ + | xargs -0 -n1 php -l 2>&1 | tee ux-check-evidence/heading-php-lint.log + php -l wordpress-plugins/calorieapp-account-profile/calorieapp-account-profile.php + php wordpress-plugins/calorieapp-account-profile/tests/profile-test.php + python tools/build_heading_repair_release.py \ + --output-dir ux-check-evidence/heading-release \ + 2>&1 | tee ux-check-evidence/heading-release.log + cd frontend + ./node_modules/.bin/tsc --noEmit 2>&1 | tee ../ux-check-evidence/typecheck.log + npm run build 2>&1 | tee ../ux-check-evidence/build.log + - name: Production-browser check with synthetic backend only + shell: bash + run: | + set -euo pipefail + npm run start --prefix frontend -- --hostname 127.0.0.1 --port 3100 > ux-check-evidence/server.log 2>&1 & + server_pid=$! + trap 'kill "$server_pid" 2>/dev/null || true' EXIT + for i in $(seq 1 30); do curl --silent --fail http://127.0.0.1:3100/ > /dev/null && break; sleep 1; done + curl --silent --fail http://127.0.0.1:3100/ > /dev/null + # Collect every independent browser result even if one flow fails. + browser_failed=0 + python tools/tests/browser_food_ux.py 2>&1 | tee ux-check-evidence/browser.log || browser_failed=1 + python tools/tests/browser_account_profile.py 2>&1 | tee ux-check-evidence/account-profile-browser.log || browser_failed=1 + python tools/tests/browser_account_guides.py 2>&1 | tee ux-check-evidence/account-guides-browser.log || browser_failed=1 + HEADING_NUTRITION_EVIDENCE_DIR=ux-check-evidence/heading-nutrition-browser \ + python tools/tests/browser_heading_nutrition.py \ + 2>&1 | tee ux-check-evidence/heading-nutrition-browser.log || browser_failed=1 + test "$browser_failed" -eq 0 + python - <<'PY' + import json + from pathlib import Path + profile=json.loads(Path('ux-check-evidence/account-profile-browser/report.json').read_text()) + assert profile['status']=='passed' and not profile['errors'] and len(profile['checks'])>=40, profile + r=json.loads(Path('ux-check-evidence/browser/report.json').read_text()) + assert r['status']=='passed' and not r['errors'], r + assert len(r['checks']) >= 55 and len(r['writes']) == 1, r + h=json.loads(Path('ux-check-evidence/heading-nutrition-browser/report.json').read_text()) + assert h['status']=='passed' and not h['errors'], h + assert len(h['checks']) >= 57, h + a=json.loads(Path('ux-check-evidence/account-guides-browser/report.json').read_text()) + assert a['status']=='passed' and not a['errors'] and not a['unexpected_requests'], a + assert len(a['checks']) >= 40 and a['faucet_requests']==1, a + PY + - name: Retain test evidence (no publication or deployment) + if: always() + uses: actions/upload-artifact@v4 + with: + name: food-ux-browser-evidence + path: ux-check-evidence/ + retention-days: 3 + if-no-files-found: error diff --git a/.github/workflows/wordpress-content-check.yml b/.github/workflows/wordpress-content-check.yml new file mode 100644 index 00000000..dff729a1 --- /dev/null +++ b/.github/workflows/wordpress-content-check.yml @@ -0,0 +1,52 @@ +name: WordPress content styling check +on: + push: + branches: [repair/food-ux-integration-20260915] + paths: + - 'wordpress-plugins/calorietoken-heading-repair/**' + - 'tools/tests/browser_wordpress_content.py' + - 'tools/tests/fixtures/wordpress-content/**' + - '.github/workflows/wordpress-content-check.yml' + pull_request: + branches: [herstel/vervolg-20260915] + paths: + - 'wordpress-plugins/calorietoken-heading-repair/**' + - 'tools/tests/browser_wordpress_content.py' + - 'tools/tests/fixtures/wordpress-content/**' + - '.github/workflows/wordpress-content-check.yml' +permissions: + contents: read +concurrency: + group: wordpress-content-${{ github.ref }} + cancel-in-progress: true +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install isolated browser tools + run: | + python -m pip install 'playwright==1.57.0' + python -m playwright install --with-deps chromium + - name: Verify package and render public-content fixtures + run: | + mkdir -p ux-check-evidence + git rev-parse HEAD > ux-check-evidence/verified-commit.txt + git rev-parse 'HEAD^{tree}' > ux-check-evidence/verified-tree.txt + php -l wordpress-plugins/calorietoken-heading-repair/calorietoken-heading-repair.php + python -m unittest tools.tests.test_build_heading_repair_release + python tools/build_heading_repair_release.py --output-dir ux-check-evidence/heading-release + python tools/tests/browser_wordpress_content.py + - name: Retain preview and verification evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: wordpress-content-style-evidence + path: ux-check-evidence/ + retention-days: 5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9812d8e1..aaa3593a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,22 @@ Do not submit code, documentation, designs, data, or other material unless you have the right to do so and the project owner has agreed in writing to review the contribution. +## Independent ecosystem projects + +Building an independent compatible project is different from contributing code +to the official repository. + +An independent project does not need prior operator approval merely to create +its own lawful implementation against public ecosystem interfaces, provided it +uses its own namespace/branding, respects applicable licences and third-party +rights, and does not claim official status or access private production state. + +The official repository still has its own inbound contribution requirements +below. Open compatibility does not silently relicense official source code. + +See `docs/ECOSYSTEM_OPEN_STEWARDSHIP_AND_FUNDING.md` and +`contracts/ecosystem/v2/open-stewardship-and-funding.json`. + ## No implied acceptance or transfer - Opening an issue or pull request does not mean a contribution is accepted. diff --git a/DATA_LICENSING.md b/DATA_LICENSING.md index 73ccf6c4..845b7e3f 100644 --- a/DATA_LICENSING.md +++ b/DATA_LICENSING.md @@ -56,6 +56,32 @@ The current frontend bundles three dated reference-food records from USDA FoodDa The interface does not silently combine alternative energy methods, treat missing data as measured zero, or import these examples into a private diary. USDA reference provenance remains separate from Open Food Facts licensing and private user records. External names and marks retain their own rights. +## USDA search catalogue — live since 15 September 2026 + +The food-discovery continuation adds a separate, dated snapshot in +`frontend/public/data/usda-search-foods.json`: 363 Foundation records from April +2026 and 7,793 SR Legacy records from April 2018. It does not claim to cover all +FoodData Central collections. The original three-food reference remains intact. + +The catalogue preserves FDC identifiers, English source descriptions, collection, +edition, nutrient units and original numeric precision. Its source manifest records +the original download URLs and archive SHA-256 values. The builder is +`tools/build_usda_search_catalog.py`; the catalogue is kept separate from OFF data. +FoodData Central's official [download page](https://fdc.nal.usda.gov/download-datasets/) +and [documentation](https://fdc.nal.usda.gov/data-documentation/) describe these +collections and reuse conditions. + +The browser requests one fixed first-party catalogue file only after a USDA +search is submitted. Search matching then runs locally; no search phrase or +account identifier is sent to USDA. Opening a source link visits USDA separately. +Saving requires the existing explicit portion confirmation and authenticated +backend route. The diary entry retains the USDA source, FDC identifier and chosen +gram basis; it has no fabricated barcode or Nutri-Score. + +Name-based alternatives help visitors inspect other records. They are not +personalised nutrition recommendations, allergen checks or claims of healthier +equivalence. The visitor must check the actual label, preparation and portion. + ## User and identity data Authentication identifiers and food logs are application data, not assets diff --git a/IP_CLEARANCE.md b/IP_CLEARANCE.md index a3b84595..81aaa113 100644 --- a/IP_CLEARANCE.md +++ b/IP_CLEARANCE.md @@ -5,3 +5,23 @@ This repository publishes reviewed application expression; it does not claim own Contributions require documented provenance and compatible rights. Dependencies, datasets, images, names and marks retain their own licences and restrictions. Open Food Facts reuse must preserve the attribution and database-licence requirements described in `DATA_LICENSING.md`. A fresh legal and technical review is required before adding financial execution, wallet custody, regulated claims, bulk datasets, biometric or health profiling, new branding, restrictive SDKs or copied third-party material. This document is an engineering boundary, not legal advice or a freedom-to-operate opinion. + + +## CalorieVerse / open ecosystem clearance gates + +The proposed CalorieVerse name has not been declared cleared merely because no +obvious conflict appeared in a general web search. Before a commercial public +launch or filing, perform an exact trade-mark clearance search across the +relevant EUIPO/TMview/BOIP records, classes and similar names, and retain the +search evidence. + +The long-term intention to let third parties build on ecosystem protocols does +not automatically change this repository's current licence. Existing code, +assets and documentation require a rights/provenance audit before they can be +placed into an open-licence manifest. Newly created protocol/SDK components may +use a recognised open-source licence only after the relevant rights holder has +approved that licence and the component is explicitly identified. + +Creator-uploaded content needs its own production terms, rights warranty, +licence scope, notice/takedown process and moderation rules before public +hosting is enabled. diff --git a/README.md b/README.md index 9fb1b29a..82041371 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,26 @@ Current application stack: - Frontend: Next.js + TypeScript + Tailwind - Backend: FastAPI + SQLModel - Data: SQLite for local development and tests; PostgreSQL is required for live user data -- External food data: Open Food Facts search adapter; a separate three-food USDA reference selection +- External food data: Open Food Facts search adapter; a dated USDA search catalogue and separate reference selection - Identity/authentication: server-side identity flow with session cookies +### Live food-discovery update — 15 September 2026 + +The live continuation adds a separate search of 8,156 dated USDA Foundation +and SR Legacy records, an edible-gram preview, and optional similar-name food +choices. Interface text covers the existing eleven display languages. Selecting +a food opens the existing portion confirmation; it does not save automatically. +The existing barcode flow is preserved. App commit `40ed5f4` was deployed and +the new flow was checked live in all eleven interface languages. CalorieHelp +now explains these steps on the website. See the +[feature and validation record](docs/public/food-discovery-2026-09.md) and +[CalorieHelp update](docs/public/caloriehelp-2026-09.md). + ## Current Status ### Implemented in the repository (V2 completion in progress) -The latest website package and app journey still need live owner acceptance. Current source additions include eleven-language UI synchronization, startup/rate-limit feedback, diary filtering, attributed USDA reference foods and a Testnet-guide link. Historical page translations are only partly complete; see the [dated scope and verification record](docs/public/website-update-2026-09.md). +The food-discovery update and targeted CalorieHelp update are live. Complete mobile website acceptance and updated campaign material remain open. Existing capabilities include eleven-language UI synchronization, startup/rate-limit feedback, diary filtering, dated USDA search and reference foods, comparable-food choices and a Testnet-guide link. Historical page translations are only partly complete; see the [dated scope and verification record](docs/public/website-update-2026-09.md). - Food search via backend integration with Open Food Facts - Nutrition result display in the web UI diff --git a/REGULATORY.md b/REGULATORY.md index 409ed7d6..7c4312a8 100644 --- a/REGULATORY.md +++ b/REGULATORY.md @@ -8,3 +8,19 @@ investment services or financial advice. External identity may be used only for authentication context. Any future financial, token, reward, health-profiling or regulated feature requires separate legal, privacy, security and architecture review before public claims or implementation. This summary is not legal advice and does not claim certification or regulatory approval. + + +## CalorieVerse / CalorieStudio commercial boundary + +Optional premium digital features, creator tools or managed services may be +introduced as a sustainability model, but charging EU consumers requires a +separate consumer-contract, VAT/OSS, privacy and refund review before launch. + +If CalorieStudio or CalorieVerse begins hosting/intermediating public +user-generated content or marketplace activity, Digital Services Act and +platform/content-moderation obligations must be reviewed before production +activation. + +Real CAL/XRP payment rails, crypto-asset rewards, exchange/custody/transfer +services or other token-linked commercial functions remain outside the current +non-financial boundary and require separate MiCA/financial-regulatory review. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3a66ec26..1bd615bd 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -27,3 +27,16 @@ source-clearance work are recorded in `DATA_LICENSING.md`. The standalone WordPress Site Style component in `wordpress-plugins/calorietoken-site-style/` also declares GPL-2.0-or-later. Its packaged licence applies to its code. Historical site images/fonts remain references to the existing site and retain their original rights; they are not granted a new licence here. The display-language runtime is shared with CalorieApp. + +The optional-on-use food barcode decoder bundles `@zxing/browser` 0.1.5 (MIT), +`@zxing/library` 0.21.3 (Apache-2.0, with its included additional notices), and +`ts-custom-error` as resolved in the lockfile (MIT). Complete upstream texts +are retained at `frontend/public/barcode-licenses.txt`, served with the app. +The libraries decode locally; no CDN service or external image processing is +used. This does not relicense the surrounding CalorieApp or brand. + +The ZXing library's npm metadata says MIT, while its shipped LICENSE retains +Apache-2.0 and additional upstream notices. This release preserves that full +file and does not treat the metadata as a blanket relicense. The optional +`@zxing/text-encoding` 0.9.0 dependency's complete LICENSE.md is retained too; +it identifies its public-domain/Apache-2.0 terms and Encoding Standard material. diff --git a/TRADEMARKS.md b/TRADEMARKS.md index 9c0afc86..ffc4bcf2 100644 --- a/TRADEMARKS.md +++ b/TRADEMARKS.md @@ -32,3 +32,40 @@ Registration of a figurative mark does not by itself establish copyright authorship or assignment. The copyright chain for the finished CalorieToken logo artwork remains subject to separate provenance and assignment verification. Trade-mark rights and copyright in artwork are distinct rights. + + +## CalorieVerse and future ecosystem marks + +`CalorieVerse` is the current proposed public name for the project's live +metaverse/open-world product. This repository does **not** claim that +CalorieVerse is a registered trade mark. The name requires an exact clearance +search for the intended goods/services before any registration or registered +symbol is claimed. A general web search is not a freedom-to-operate opinion. + +Open ecosystem compatibility does not itself grant a right to use official +branding in a misleading way. Independent implementations may need a future +trade-mark policy for truthful compatibility references, community naming and +conformance badges. + +Long-term decentralisation does not require the current proprietor to abandon +marks. A future stewardship entity may receive ownership of, or a documented +licence to administer, marks only through an explicit legal instrument and any +required registry recordal. No transfer occurs automatically through code +governance, token ownership, community participation or publication of an open +protocol. + +Until a separate decision and legal instrument exist, the current ownership +records control. + + +### CalorieStudio + +`CalorieStudio` is the current proposed public name for the creator/workshop +surface within CalorieVerse. The internal route `/gallery` and legacy +"Creator Gallery" identifiers may remain for compatibility. + +This repository does **not** claim that CalorieStudio is a registered trade +mark or that the name has received a formal freedom-to-operate clearance. +Before a commercial brand filing or registered-symbol claim, perform an exact +EUIPO/TMview/BOIP clearance search for the intended goods and services and +retain the evidence. diff --git a/backend/README.md b/backend/README.md index be1f746b..8a81f4d4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -87,3 +87,22 @@ migration or verified restore. later without paywalling identity or personal-data rights. - Open Food Facts is consumed only by backend service endpoints and is the current adapter, not the canonical or exclusive food-data model. + + +### Public product search + +Name searches use Open Food Facts' indexed Search-a-licious API. The request is +read-only, sends only literal product-name text and requested nutrition/display +fields, and supports the application's eleven display languages. One bounded +legacy CGI transport fallback is allowed after a transport or invalid-response +failure, never after a provider HTTP rejection (including 429/503). Barcode +lookup continues to use the exact v3 product endpoint and GTIN verification. + +Successful results are cached for one hour in a 256-entry process-local cache. +Case and repeated whitespace share one name-search key. A cached answer remains +available during a provider cooldown, without another source request. Empty or +failed responses are not cached. The cache is lost on restart; it is not a local +copy of the complete OFF database. Existing shared PostgreSQL egress quotas, +queue limits, duplicate coalescing and Retry-After pauses remain in force. + +Source documentation: https://openfoodfacts.github.io/search-a-licious/users/ref-openapi/ diff --git a/backend/app/account_data_import.py b/backend/app/account_data_import.py index e455d201..ebaed6f8 100644 --- a/backend/app/account_data_import.py +++ b/backend/app/account_data_import.py @@ -346,9 +346,17 @@ def _validate_shape(parsed: dict[str, Any]) -> str: else _V2_TOP_LEVEL_FIELDS ) _require_exact_fields(parsed, expected_top_level, field_name="payload") + # Optional v2 profile extension: older exports remain valid. The nickname + # is exported for access, but never copied to another account by food import. + account_fields = _ACCOUNT_FIELDS + if export_version != LEGACY_EXPORT_VERSION and isinstance(parsed["account"], dict) and "nickname" in parsed["account"]: + account_fields = _ACCOUNT_FIELDS | {"nickname"} + nickname = parsed["account"]["nickname"] + if nickname is not None and (not isinstance(nickname, str) or not 2 <= len(nickname) <= 32): + raise AccountDataImportSafetyError("account.nickname is invalid") account = _require_exact_fields( parsed["account"], - _ACCOUNT_FIELDS, + account_fields, field_name="account", ) _require_explicit_timezone(parsed["exported_at"], field_name="exported_at") diff --git a/backend/app/bridge_codes.py b/backend/app/bridge_codes.py new file mode 100644 index 00000000..81b24a69 --- /dev/null +++ b/backend/app/bridge_codes.py @@ -0,0 +1,138 @@ +"""Short-lived codes issued for authenticated WordPress identity assertions. + +Uses the existing authorization-code table and origin-browser callback. No +session is created by the bridge request; only the callback may consume it. +""" + +import json +from datetime import UTC, datetime, timedelta +from hashlib import sha256 +from secrets import compare_digest, token_urlsafe + +from fastapi import HTTPException +from sqlalchemy import delete, update +from sqlmodel import Session, select + +from .models import AuthorizationCodeDB, PendingLoginStateDB +from .schemas import BridgeCodeRequest, BridgeCodeResponse, IdentityClaimsResponse +from .services.identity import get_pending_login_locale, hash_login_state + +BACKEND_CODE_PREFIX = "cb1." +BRIDGE_CODE_CONTEXT = "issue_login_code_v1" +BACKEND_CODE_RECORD_PREFIX = "bridge-code:" + + +def bridge_code_canonical_payload( + *, client_id: str, timestamp: int, nonce: str, payload: BridgeCodeRequest +) -> str: + # Fixed field order, UTF-8, no whitespace; mirrored by WordPress. + return json.dumps( + { + "version": "v2", + "purpose": BRIDGE_CODE_CONTEXT, + "client_id": client_id, + "timestamp": str(timestamp), + "nonce": nonce, + "state": payload.state, + "external_subject": payload.external_subject, + "xrpl_address": payload.xrpl_address, + "locale": payload.locale, + }, + ensure_ascii=False, + separators=(",", ":"), + ) + + +def issue_bridge_code( + session: Session, payload: BridgeCodeRequest, *, client_id: str +) -> BridgeCodeResponse: + now = datetime.now(UTC) + state_hash = hash_login_state(payload.state) + # Expire only this transport's cache records, in a bounded batch. Retain + # all rows for an unexpired state so its three-code allowance never resets. + live_state = ( + select(PendingLoginStateDB.id) + .where(PendingLoginStateDB.state_hash == AuthorizationCodeDB.state) + .where(PendingLoginStateDB.expires_at >= now) + .exists() + ) + expired_ids = session.exec( + select(AuthorizationCodeDB.id) + .where(AuthorizationCodeDB.login_session_id.startswith(BACKEND_CODE_RECORD_PREFIX)) + .where(AuthorizationCodeDB.expires_at < now) + .where(~live_state) + .order_by(AuthorizationCodeDB.expires_at, AuthorizationCodeDB.id) + .limit(200) + ).all() + if expired_ids: + session.exec(delete(AuthorizationCodeDB).where(AuthorizationCodeDB.id.in_(expired_ids))) + # Serialize issuance per login transaction on PostgreSQL. This also + # serializes against the callback's atomic pending-state reservation. + pending = session.exec( + select(PendingLoginStateDB) + .where(PendingLoginStateDB.state_hash == state_hash) + .with_for_update() + ).first() + if ( + pending is None + or pending.status != "pending" + or pending.consumed_at is not None + or pending.client_id != client_id + or pending.expires_at.replace(tzinfo=UTC) <= now + ): + raise HTTPException(400, "Unknown, expired or consumed login state") + if get_pending_login_locale(session, payload.state) != payload.locale: + raise HTTPException(409, "Login locale mismatch") + existing = session.exec( + select(AuthorizationCodeDB.id).where( + AuthorizationCodeDB.login_session_id == BACKEND_CODE_RECORD_PREFIX + pending.id + ) + ).all() + if len(existing) >= 3: + raise HTTPException(429, "Authorization refresh limit reached") + + code = BACKEND_CODE_PREFIX + token_urlsafe(32) + expires_at = min(pending.expires_at.replace(tzinfo=UTC), now + timedelta(seconds=60)) + row = AuthorizationCodeDB( + code_hash=sha256(code.encode("utf-8")).hexdigest(), + external_subject=payload.external_subject, + xrpl_address=payload.xrpl_address, + state=state_hash, + login_session_id=BACKEND_CODE_RECORD_PREFIX + pending.id, + created_at=now, + expires_at=expires_at, + ) + session.add(row) + session.commit() + return BridgeCodeResponse(code=code, expires_at=expires_at, jti=row.id, locale=payload.locale) + + +def consume_bridge_code(session: Session, *, code: str, state: str) -> IdentityClaimsResponse: + now = datetime.now(UTC) + row = session.exec( + select(AuthorizationCodeDB).where( + AuthorizationCodeDB.code_hash == sha256(code.encode("utf-8")).hexdigest() + ) + ).first() + if row is None or not compare_digest(row.state, hash_login_state(state)): + raise HTTPException(400, "Authorization code exchange rejected") + changed = session.exec( + update(AuthorizationCodeDB) + .where(AuthorizationCodeDB.id == row.id) + .where(AuthorizationCodeDB.used_at.is_(None)) + .where(AuthorizationCodeDB.expires_at > now) + .values(used_at=now) + .execution_options(synchronize_session=False) + ).rowcount + if changed != 1: + session.rollback() + raise HTTPException(400, "Authorization code exchange rejected") + claims = IdentityClaimsResponse( + external_subject=row.external_subject, + xrpl_address=row.xrpl_address, + issued_at=row.created_at, + expires_at=row.expires_at, + jti=row.id, + ) + session.commit() + return claims diff --git a/backend/app/food_log_view.py b/backend/app/food_log_view.py new file mode 100644 index 00000000..f6d29654 --- /dev/null +++ b/backend/app/food_log_view.py @@ -0,0 +1,67 @@ +"""Owner-scoped diary pages and whole-period totals, without changing stored logs.""" +from datetime import UTC, datetime + +from fastapi import HTTPException +from sqlalchemy import and_, case, func +from sqlmodel import Session, select + +from .models import FoodLogDB +from .schemas import FoodLog, FoodLogOverview + + +def food_log_overview(session: Session, owner_id: int, start: datetime | None, + end: datetime | None, before: int | None, limit: int) -> FoodLogOverview: + if (start is None) != (end is None): + raise HTTPException(422, "Supply both start and end, or neither") + conditions = [FoodLogDB.owner_id == owner_id] + if start is not None and end is not None: + if start.utcoffset() is None or end.utcoffset() is None: + raise HTTPException(422, "Diary boundaries must include a time zone") + if end <= start or (end - start).total_seconds() > 32 * 86400: + raise HTTPException(422, "Diary range must be positive and at most 32 days") + # The existing table stores UTC in timezone-naive SQL DateTime columns. + conditions += [FoodLogDB.created_at >= start.astimezone(UTC).replace(tzinfo=None), + FoodLogDB.created_at < end.astimezone(UTC).replace(tzinfo=None)] + # New CalorieApp entries already carry enough bounded provenance to report + # the two active discovery lanes without changing private stored rows: + # OFF products retain their product barcode, while our fixed USDA lane uses + # the exact FoodData Central brand prefix and intentionally has no barcode. + # Anything that cannot be established from those fields remains "other"; + # it is never guessed into either source. + open_food_facts = and_( + FoodLogDB.barcode.is_not(None), + func.length(func.trim(FoodLogDB.barcode)) > 0, + ) + usda = and_( + FoodLogDB.barcode.is_(None), + func.lower(func.trim(func.coalesce(FoodLogDB.brand, ""))).like( + "usda fooddata central · fdc %" + ), + ) + columns = [func.count(FoodLogDB.id)] + [ + func.coalesce(func.sum(getattr(FoodLogDB, key)), 0) + for key in ("calories", "protein", "fat", "carbohydrates") + ] + [func.coalesce(func.sum(case((and_(open_food_facts, + func.upper(func.trim(FoodLogDB.nutri_score)) == grade), 1), else_=0)), 0) + for grade in "ABCDE"] + [ + func.coalesce(func.sum(case((open_food_facts, 1), else_=0)), 0), + func.coalesce(func.sum(case((usda, 1), else_=0)), 0), + ] + totals = session.exec(select(*columns).where(*conditions)).one() + page_conditions = conditions + ([FoodLogDB.id < before] if before is not None else []) + entries = session.exec(select(FoodLogDB).where(*page_conditions) + .order_by(FoodLogDB.id.desc()).limit(limit + 1)).all() + open_food_facts_count = int(totals[10]) + usda_count = int(totals[11]) + total_count = int(totals[0]) + return FoodLogOverview( + entries=[FoodLog.model_validate(row.model_dump()) for row in entries[:limit]], + next_before=entries[limit - 1].id if len(entries) > limit else None, + count=total_count, calories=totals[1], protein=totals[2], fat=totals[3], carbohydrates=totals[4], + grades=dict(zip("ABCDE", totals[5:10])), + sources={ + "open_food_facts": open_food_facts_count, + "usda": usda_count, + "other": total_count - open_food_facts_count - usda_count, + }, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 2a13e50d..9f6e1d8a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,6 +21,13 @@ from sqlmodel import Session, select from . import database as db_module +from .bridge_codes import ( + BACKEND_CODE_PREFIX, + BRIDGE_CODE_CONTEXT, + bridge_code_canonical_payload, + consume_bridge_code, + issue_bridge_code, +) from .account_data_import import ( AccountDataImportSafetyError, plan_account_data_import, @@ -42,6 +49,8 @@ validate_capacity_configuration, ) from .database import database_readiness, get_session, init_db +from .food_log_view import food_log_overview +from .schemas import FoodLogOverview from .data_growth import ( DataGrowthAdmissionRejected, create_food_log_with_subject_budget, @@ -77,6 +86,11 @@ AccountExportImportReceipt, AccountExportLoginHandoff, CurrentUserResponse, + NicknameUpdateRequest, + NicknameResponse, + WordpressProfileRequest, + BridgeCodeRequest, + BridgeCodeResponse, FoodLog, FoodLogCreate, IdentityCallbackResponse, @@ -106,7 +120,7 @@ validate_identity_start_admission_configuration, validate_origin_login_handoff, ) -from .services.open_food_facts import search_food_products +from .services.open_food_facts import search_food_products, valid_food_barcode from .services.food_search_availability import FoodSearchUnavailable logger = logging.getLogger(__name__) @@ -118,10 +132,11 @@ BRIDGE_STATE_VALIDATE_CONTEXT = "login_state_validate" -def _build_identifier(value: str | None) -> str: +def _build_identifier(value: str | None, *, render_commit: str | None = None) -> str: candidate = value.strip() if value else "" if not candidate: - return "development" + commit = render_commit.strip() if render_commit else "" + return commit if re.fullmatch(r"[A-Fa-f0-9]{40}", commit) else "development" if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", candidate) is None: raise RuntimeError( "CALORIEAPP_BUILD_ID must be 1-64 letters, digits, dots, " @@ -151,7 +166,9 @@ def _build_identifier(value: str | None) -> str: _SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE", "lax").strip().lower() _CALORIEAPP_ENV_RAW = os.getenv("CALORIEAPP_ENV") _CALORIEAPP_ENV = _CALORIEAPP_ENV_RAW.strip().lower() if _CALORIEAPP_ENV_RAW and _CALORIEAPP_ENV_RAW.strip() else None -_CALORIEAPP_BUILD_ID = _build_identifier(os.getenv("CALORIEAPP_BUILD_ID")) +_CALORIEAPP_BUILD_ID = _build_identifier( + os.getenv("CALORIEAPP_BUILD_ID"), render_commit=os.getenv("RENDER_GIT_COMMIT") +) _BRIDGE_AUTH_MAX_AGE_SECONDS = int(os.getenv("BRIDGE_AUTH_MAX_AGE_SECONDS", "300")) _BRIDGE_AUTH_MAX_FUTURE_SECONDS = int(os.getenv("BRIDGE_AUTH_MAX_FUTURE_SECONDS", "30")) _BRIDGE_NONCE_RETENTION_SECONDS = int( @@ -505,11 +522,12 @@ def _reserve_bridge_auth_nonce( return True -def _authenticate_bridge_state_validate_request( +def _authenticate_bridge_request( *, request: Request, session: Session, state: str, + code_payload: Optional[BridgeCodeRequest] = None, ) -> tuple[bool, str]: if not _WORDPRESS_BRIDGE_SECRET: return False, "missing_config" @@ -552,6 +570,10 @@ def _authenticate_bridge_state_validate_request( nonce=nonce, state=state, ) + if code_payload is not None: + canonical_payload = bridge_code_canonical_payload( + client_id=client_id, timestamp=timestamp, nonce=nonce, payload=code_payload + ) expected_signature = _bridge_auth_signature(canonical_payload, _WORDPRESS_BRIDGE_SECRET) if not compare_digest(signature.lower(), expected_signature): return False, "invalid_signature" @@ -561,7 +583,7 @@ def _authenticate_bridge_state_validate_request( session, client_id=client_id, nonce=nonce, - context=BRIDGE_STATE_VALIDATE_CONTEXT, + context=BRIDGE_CODE_CONTEXT if code_payload is not None else BRIDGE_STATE_VALIDATE_CONTEXT, ) if not reserved: return False, "replayed_nonce" @@ -808,6 +830,17 @@ def _exchange_code_for_claims(code: str, state: str) -> IdentityClaimsResponse: logger.warning("WordPress bridge rejected code exchange (status=%s)", response.status_code) raise HTTPException(status_code=400, detail="Authorization code exchange rejected") + content_type = response.headers.get("content-type", "").partition(";")[0].strip().lower() + if content_type == "text/html": + # Hosting verification pages can return 200 before WordPress runs. A + # browser retry cannot complete that server-to-server check. Expose a + # fixed error code, never the page body, headers, URL, or credentials. + logger.warning("WordPress bridge returned HTML instead of identity JSON") + raise HTTPException( + status_code=502, + detail={"code": "wordpress_bridge_html_response"}, + ) + try: payload = response.json() except ValueError as exc: @@ -925,7 +958,7 @@ def identity_validate_pending_state( session: DbSession, ) -> IdentityStateValidationResponse: """Server-to-server endpoint for bridge validation of pending login state.""" - authenticated, reason = _authenticate_bridge_state_validate_request( + authenticated, reason = _authenticate_bridge_request( request=request, session=session, state=payload.state, @@ -963,6 +996,26 @@ def identity_validate_pending_state( ) +@app.post("/api/identity/bridge/code", response_model=BridgeCodeResponse) +def identity_issue_bridge_code( + request: Request, + payload: BridgeCodeRequest, + session: DbSession, +) -> BridgeCodeResponse: + """Accept a signed WordPress assertion and issue only a one-time code.""" + authenticated, reason = _authenticate_bridge_request( + request=request, session=session, state=payload.state, code_payload=payload + ) + if not authenticated: + if reason == "missing_config": + raise HTTPException(500, "Bridge authentication is not configured") + raise HTTPException(403, "Bridge authentication failed") + subject_prefix = "wp:" + str(urlsplit(_WORDPRESS_URL).hostname).lower() + ":" + if not re.fullmatch(re.escape(subject_prefix) + r"[1-9][0-9]*", payload.external_subject): + raise HTTPException(400, "Invalid WordPress identity subject") + return issue_bridge_code(session, payload, client_id=_CALORIEAPP_CLIENT_ID) + + @app.post("/api/identity/callback", response_model=IdentityCallbackResponse) def identity_callback( payload: IdentityCallbackRequest, @@ -994,7 +1047,10 @@ def identity_callback( raise HTTPException(status_code=400, detail="Unknown login state") try: - claims = _exchange_code_for_claims(code=code, state=state) + if code.startswith(BACKEND_CODE_PREFIX): + claims = consume_bridge_code(session, code=code, state=state) + else: + claims = _exchange_code_for_claims(code=code, state=state) except HTTPException as exc: if exc.status_code in {429, 502, 503, 504}: restored = restore_pending_login_state_after_transient_failure(session, state) @@ -1146,7 +1202,53 @@ def identity_me( return CurrentUserResponse( user_id=current_user.id, created_at=current_user.created_at, + nickname=current_user.nickname, + ) + + +@app.post("/api/identity/profile", response_model=CurrentUserResponse) +def identity_update_profile( + payload: NicknameUpdateRequest, + request: Request, + session: DbSession, + current_user: CurrentUser, +) -> CurrentUserResponse: + # Require a non-simple request at both the public proxy and the backend. + if request.headers.get("x-calorieapp-request") != "account-profile": + raise HTTPException(status_code=403, detail="Profile request marker required") + origin = request.headers.get("origin") + if origin and origin not in _CORS_ORIGINS: + raise HTTPException(status_code=403, detail="Origin not allowed") + if payload.user_id != current_user.id: + raise HTTPException(status_code=409, detail="Account changed; reload your profile") + current_user.nickname = payload.nickname + current_user.updated_at = datetime.now(UTC).replace(tzinfo=None) + session.add(current_user) + session.commit() + session.refresh(current_user) + return CurrentUserResponse(user_id=current_user.id, created_at=current_user.created_at, nickname=current_user.nickname) + + +@app.post("/api/identity/profile/wordpress", response_model=NicknameResponse) +def identity_wordpress_profile( + payload: WordpressProfileRequest, + request: Request, + session: DbSession, +) -> NicknameResponse: + # Domain separation binds the signature to this read and this exact account. + authenticated, _ = _authenticate_bridge_request( + request=request, session=session, state="account-profile-v1:" + payload.external_subject, ) + if not authenticated: + raise HTTPException(status_code=403, detail="Profile authentication failed") + user = session.exec( + select(CalorieAppUserDB).join(ExternalIdentityDB).where( + ExternalIdentityDB.provider == _IDENTITY_PROVIDER, + ExternalIdentityDB.external_subject == payload.external_subject, + CalorieAppUserDB.status == "active", + ) + ).first() + return NicknameResponse(nickname=user.nickname if user else None) @app.get("/api/identity/export", response_model=AccountDataExportResponse) @@ -1221,6 +1323,7 @@ def identity_export( account=AccountExportAccount( user_id=current_user.id, status=current_user.status, + nickname=current_user.nickname, created_at=current_user.created_at, updated_at=current_user.updated_at, last_authenticated_activity_at=( @@ -1640,6 +1743,20 @@ def get_logs( return [FoodLog.model_validate(e.model_dump()) for e in entries] +@app.get("/logs/overview", response_model=FoodLogOverview) +def get_log_overview( + session: DbSession, + current_user: CurrentUser, + response: Response, + start: datetime | None = None, + end: datetime | None = None, + before: int | None = Query(default=None, ge=1), + limit: int = Query(default=100, ge=1, le=200), +) -> FoodLogOverview: + response.headers["Cache-Control"] = "private, no-store" + return food_log_overview(session, current_user.id, start, end, before, limit) + + @app.delete("/logs/{log_id}") def delete_log( log_id: int, @@ -1684,13 +1801,16 @@ def delete_all_logs( @app.get("/search-food", response_model=FoodSearchResponse) -async def search_food(q: str = Query(..., min_length=1, max_length=120)) -> FoodSearchResponse: +async def search_food(q: str = Query(..., min_length=1, max_length=120), mode: str = Query("name", pattern="^(name|barcode)$")) -> FoodSearchResponse: query = q.strip() if not query: raise HTTPException(status_code=422, detail="Search query must contain visible characters") + if mode == "barcode" and valid_food_barcode(query) is None: + raise HTTPException(status_code=422, detail="Invalid food barcode") + try: - results = await search_food_products(query) + results = await search_food_products(query, barcode=True) if mode == "barcode" else await search_food_products(query) except FoodSearchUnavailable as exc: logger.warning("Open Food Facts unavailable (status=%s)", exc.status_code) raise HTTPException( diff --git a/backend/app/models.py b/backend/app/models.py index 965db944..538cc549 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -487,6 +487,7 @@ class CalorieAppUserDB(SQLModel, table=True): default_factory=utc_now, index=True, ) + nickname: Optional[str] = Field(default=None, max_length=32) class InactiveAccountNoticeDB(SQLModel, table=True): diff --git a/backend/app/request_limits.py b/backend/app/request_limits.py index bba9e300..59af0c7d 100644 --- a/backend/app/request_limits.py +++ b/backend/app/request_limits.py @@ -13,6 +13,9 @@ ROUTE_BODY_LIMIT_BYTES: dict[tuple[str, str], int] = { ("POST", "/api/identity/login/start"): 2 * 1024, ("POST", "/api/identity/login/state/validate"): 2 * 1024, + ("POST", "/api/identity/bridge/code"): 2 * 1024, + ("POST", "/api/identity/profile"): 2 * 1024, + ("POST", "/api/identity/profile/wordpress"): 2 * 1024, ("POST", "/api/identity/callback"): 4 * 1024, ("POST", "/api/identity/login/status"): 4 * 1024, ("POST", "/api/identity/import"): 5 * 1024 * 1024, diff --git a/backend/app/route_rate_limiter.py b/backend/app/route_rate_limiter.py index b9e1391e..f3d3e201 100644 --- a/backend/app/route_rate_limiter.py +++ b/backend/app/route_rate_limiter.py @@ -55,17 +55,21 @@ def __post_init__(self) -> None: 120, ), ("POST", "/api/identity/callback"): RouteRatePolicy("identity_callback", 30), + ("POST", "/api/identity/bridge/code"): RouteRatePolicy("identity_bridge_code", 120), ("POST", "/api/identity/login/status"): RouteRatePolicy( "identity_login_status", 240, ), ("GET", "/api/identity/me"): RouteRatePolicy("identity_me", 240), + ("POST", "/api/identity/profile"): RouteRatePolicy("identity_profile", 60), + ("POST", "/api/identity/profile/wordpress"): RouteRatePolicy("identity_widget_profile", 240), ("GET", "/api/identity/export"): RouteRatePolicy("identity_export", 30), ("POST", "/api/identity/import"): RouteRatePolicy("identity_import", 5), ("DELETE", "/api/identity/account"): RouteRatePolicy("identity_account_delete", 10), ("POST", "/api/identity/logout"): RouteRatePolicy("identity_logout", 120), ("POST", "/log-food"): RouteRatePolicy("food_log_create", 120), ("GET", "/logs"): RouteRatePolicy("food_log_list", 240), + ("GET", "/logs/overview"): RouteRatePolicy("food_log_list", 240), ("DELETE", "/logs"): RouteRatePolicy("food_log_delete_all", 30), ("GET", "/search-food"): RouteRatePolicy("food_search", 60), } diff --git a/backend/app/schema_migrations/runner.py b/backend/app/schema_migrations/runner.py index a4b79f75..a512abe3 100644 --- a/backend/app/schema_migrations/runner.py +++ b/backend/app/schema_migrations/runner.py @@ -26,6 +26,7 @@ v20260902_0014, v20260902_0015, v20260902_0016, + v20260917_0017, ) @@ -138,6 +139,12 @@ class Migration: upgrade=v20260902_0016.upgrade, validate=v20260902_0016.validate, ), + Migration( + revision=v20260917_0017.revision, + down_revision=v20260917_0017.down_revision, + upgrade=v20260917_0017.upgrade, + validate=v20260917_0017.validate, + ), ) SCHEMA_HEAD = MIGRATIONS[-1].revision diff --git a/backend/app/schema_migrations/versions/v20260830_0001.py b/backend/app/schema_migrations/versions/v20260830_0001.py index 7782f7b4..5f72fc2d 100644 --- a/backend/app/schema_migrations/versions/v20260830_0001.py +++ b/backend/app/schema_migrations/versions/v20260830_0001.py @@ -200,7 +200,7 @@ # Later forward migrations may extend a baseline table. Keep this list explicit # so the baseline validator still rejects every unrelated extra column. _allowed_later_columns = { - "calorieappuser": {"last_authenticated_activity_at"}, + "calorieappuser": {"last_authenticated_activity_at", "nickname"}, "pendingloginstate": {"client_id"}, } diff --git a/backend/app/schema_migrations/versions/v20260917_0017.py b/backend/app/schema_migrations/versions/v20260917_0017.py new file mode 100644 index 00000000..d20f127a --- /dev/null +++ b/backend/app/schema_migrations/versions/v20260917_0017.py @@ -0,0 +1,19 @@ +"""Add an optional private account nickname, preserving all existing rows.""" +import sqlalchemy as sa +from sqlalchemy.engine import Connection + +revision = "20260917_0017" +down_revision = "20260902_0016" + + +def upgrade(connection: Connection) -> None: + columns = {column["name"] for column in sa.inspect(connection).get_columns("calorieappuser")} + if "nickname" not in columns: + connection.execute(sa.text("ALTER TABLE calorieappuser ADD COLUMN nickname VARCHAR(32) NULL")) + + +def validate(connection: Connection) -> None: + columns = {column["name"]: column for column in sa.inspect(connection).get_columns("calorieappuser")} + column = columns.get("nickname") + if column is None or not column["nullable"] or not isinstance(column["type"], sa.String) or column["type"].length != 32: + raise RuntimeError("Account nickname column is missing or has drifted") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f4a147a3..cd697e5d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime +import unicodedata from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -56,6 +57,18 @@ def serialize_created_at_as_utc(cls, value: datetime) -> datetime: return _ensure_utc(value) +class FoodLogOverview(BaseModel): + entries: list[FoodLog] + next_before: int | None + count: int + calories: float + protein: float + fat: float + carbohydrates: float + grades: dict[str, int] + sources: dict[str, int] + + class FoodSearchResult(BaseModel): model_config = ConfigDict(allow_inf_nan=False) @@ -160,6 +173,7 @@ class IdentityStateValidationResponse(BaseModel): valid: bool expires_at: datetime locale: str + code_transport: Literal["backend_v1"] = "backend_v1" @field_validator("expires_at", mode="after") @classmethod @@ -167,6 +181,22 @@ def serialize_expires_at_as_utc(cls, value: datetime) -> datetime: return _ensure_utc(value) +class BridgeCodeRequest(BaseModel): + """Identity asserted only by the authenticated WordPress server.""" + + state: str = Field(min_length=32, max_length=255, pattern=r"^[A-Za-z0-9._~-]+$") + external_subject: str = Field(min_length=1, max_length=120) + xrpl_address: str = Field(min_length=25, max_length=34, pattern=r"^r[1-9A-HJ-NP-Za-km-z]+$") + locale: str = Field(min_length=2, max_length=16) + + +class BridgeCodeResponse(BaseModel): + code: str + expires_at: datetime + jti: str + locale: str + + class IdentityClaimsResponse(BaseModel): """Verified identity claims from WordPress bridge.""" @@ -182,11 +212,43 @@ def normalize_claim_timestamps_to_utc(cls, value: datetime) -> datetime: return _ensure_utc(value) +class NicknameUpdateRequest(BaseModel): + """Only the authenticated account may set its private display nickname.""" + + model_config = ConfigDict(extra="forbid") + user_id: str = Field(min_length=1, max_length=255) + nickname: str | None + + @field_validator("nickname", mode="after") + @classmethod + def valid_nickname(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = unicodedata.normalize("NFC", value).strip() + if not 2 <= len(normalized) <= 32 or any( + ord(char) < 32 or 127 <= ord(char) <= 159 or char in "<>" + or 0x202A <= ord(char) <= 0x202E or 0x2066 <= ord(char) <= 0x2069 + for char in value + ): + raise ValueError("Nickname must contain 2–32 visible characters") + return normalized + + +class WordpressProfileRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + external_subject: str = Field(min_length=1, max_length=255, pattern=r"^wp:[^\s:]+:[1-9][0-9]*$") + + +class NicknameResponse(BaseModel): + nickname: str | None + + class CurrentUserResponse(BaseModel): """Current authenticated user information.""" user_id: str created_at: datetime + nickname: str | None = None @field_validator("created_at", mode="after") @classmethod @@ -199,6 +261,7 @@ class AccountExportAccount(BaseModel): user_id: str status: str + nickname: str | None = None created_at: datetime updated_at: datetime last_authenticated_activity_at: datetime diff --git a/backend/app/services/food_search_availability.py b/backend/app/services/food_search_availability.py index 399bcdbe..87669c28 100644 --- a/backend/app/services/food_search_availability.py +++ b/backend/app/services/food_search_availability.py @@ -46,8 +46,8 @@ class FoodSearchAvailability: def __init__( self, *, - max_entries: int = 64, - ttl_seconds: float = 300, + max_entries: int = 256, + ttl_seconds: float = 3600, clock: Callable[[], float] = time.monotonic, ) -> None: self.max_entries = max_entries @@ -59,32 +59,32 @@ def __init__( self._status_code = 503 @staticmethod - def _key(query: str, page_size: int) -> bytes: - return hashlib.sha256(f"{page_size}\0{query}".encode("utf-8")).digest() + def _key(query: str, page_size: int, barcode: bool = False) -> bytes: + return hashlib.sha256(f"{barcode}\0{page_size}\0{query}".encode("utf-8")).digest() def _expire(self, now: float) -> None: for key, (expires_at, _) in list(self._cache.items()): if expires_at <= now: del self._cache[key] - def get(self, query: str, page_size: int) -> list[FoodSearchResult] | None: + def get(self, query: str, page_size: int, *, barcode: bool = False) -> list[FoodSearchResult] | None: with self._lock: self._expire(self.clock()) - key = self._key(query, page_size) + key = self._key(query, page_size, barcode) entry = self._cache.get(key) if entry is None: return None self._cache.move_to_end(key) return [item.model_copy(deep=True) for item in entry[1]] - def remember(self, query: str, page_size: int, results: list[FoodSearchResult]) -> None: + def remember(self, query: str, page_size: int, results: list[FoodSearchResult], *, barcode: bool = False) -> None: # Do not turn a transient empty provider response into a cached absence. if not results: return with self._lock: now = self.clock() self._expire(now) - key = self._key(query, page_size) + key = self._key(query, page_size, barcode) self._cache[key] = ( now + self.ttl_seconds, [item.model_copy(deep=True) for item in results], diff --git a/backend/app/services/open_food_facts.py b/backend/app/services/open_food_facts.py index 4e1bb951..f92089e4 100644 --- a/backend/app/services/open_food_facts.py +++ b/backend/app/services/open_food_facts.py @@ -9,15 +9,16 @@ from collections.abc import Awaitable, Callable from typing import Any, TypeVar -from urllib.parse import urlencode +from urllib.parse import urlencode, urlsplit from urllib.request import Request, urlopen from urllib.error import HTTPError as UrllibHTTPError, URLError import httpx +from pydantic import ValidationError from app.database import engine from app.provider_rate_governor import build_provider_rate_governor -from app.schemas import FoodSearchResult +from app.schemas import FoodLogCreate, FoodSearchResult from app.services.food_search_availability import ( FoodSearchAvailability, FoodSearchUnavailable, @@ -33,12 +34,14 @@ T = TypeVar("T") OPEN_FOOD_FACTS_SEARCH_URL = "https://world.openfoodfacts.org/cgi/search.pl" +OPEN_FOOD_FACTS_INDEX_URL = "https://search.openfoodfacts.org/search" REQUEST_HEADERS = { "User-Agent": "CalorieApp/0.2.0 (https://calorietoken.net; info@calorietoken.net)", "Accept": "application/json", } _PRIMARY_TIMEOUT_SECONDS = 10.0 +_INDEX_TIMEOUT_SECONDS = 15.0 # One normal request plus at most one alternate-transport request. Nested # transport retries would amplify one user search into enough upstream traffic # to exhaust Open Food Facts' public per-IP search allowance. @@ -95,7 +98,7 @@ def _repair_common_mojibake(text: str) -> str: def _to_float(value: Any) -> float | None: - if value is None: + if value is None or isinstance(value, bool): return None try: result = float(value) @@ -104,7 +107,7 @@ def _to_float(value: Any) -> float | None: if result < 0: return None return round(result, 2) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): return None @@ -116,16 +119,27 @@ def _to_optional_text(value: Any) -> str | None: def _extract_image_url(product: dict[str, Any]) -> str | None: - """Prefer higher-quality Open Food Facts image fields when available.""" + """Prefer an exact OFF image host; unsafe/missing values use the UI fallback.""" for key in ("image_front_url", "image_url", "image_small_url", "image_front_small_url"): image_url = _to_optional_text(product.get(key)) if image_url: + try: + parsed = urlsplit(image_url) + except ValueError: + continue + if (parsed.scheme != "https" or parsed.hostname != "images.openfoodfacts.org" + or not parsed.path.startswith("/images/products/")): + continue return image_url return None def _extract_brand(product: dict[str, Any]) -> str | None: - brands = _to_optional_text(product.get("brands")) + raw_brands = product.get("brands") + # Search-a-licious returns an array; the product API uses a comma-separated string. + if isinstance(raw_brands, list): + raw_brands = next((brand for brand in raw_brands if isinstance(brand, str) and brand.strip()), None) + brands = _to_optional_text(raw_brands) if not brands: return None # Open Food Facts often returns comma-separated brands; show the first clean label. @@ -141,21 +155,24 @@ def _extract_nutri_score(product: dict[str, Any]) -> str | None: return normalized if normalized in {"A", "B", "C", "D", "E"} else None -async def search_food_products(query: str, page_size: int = 10) -> list[FoodSearchResult]: - safe_query = query.strip() - cached = _OPEN_FOOD_FACTS_AVAILABILITY.get(safe_query, page_size) +async def search_food_products(query: str, page_size: int = 10, *, barcode: bool = False) -> list[FoodSearchResult]: + safe_query = query.strip() if barcode else " ".join(query.split()).casefold() + if barcode and valid_food_barcode(safe_query) is None: + raise ValueError("Invalid food barcode") + cached = _OPEN_FOOD_FACTS_AVAILABILITY.get(safe_query, page_size, barcode=barcode) if cached is not None: return cached _OPEN_FOOD_FACTS_AVAILABILITY.check_provider() return await _OPEN_FOOD_FACTS_COALESCER.run( - (safe_query, page_size), - lambda: _search_food_products_once(safe_query, page_size), + (safe_query, page_size, barcode), + lambda: _search_food_products_once(safe_query, page_size, barcode=barcode), ) async def _search_food_products_once( safe_query: str, page_size: int, + *, barcode: bool = False, ) -> list[FoodSearchResult]: permit = _OPEN_FOOD_FACTS_ADMISSION.begin_action() params = { @@ -165,35 +182,42 @@ async def _search_food_products_once( "json": 1, "page_size": page_size, "fields": _OPEN_FOOD_FACTS_FIELDS, + # The UI does not display a total across the complete OFF database. + "no_count": 1, } try: - try: + if barcode: payload = await _OPEN_FOOD_FACTS_ADMISSION.run_attempt( - lambda: _governed_attempt(lambda: _fetch_primary(params)) - ) - except httpx.HTTPStatusError: - # Do not bypass an upstream status (especially 429/503) through - # another transport. That would multiply load precisely when the - # source asks us to stop or is unavailable. - raise - except (httpx.RequestError, ValueError) as exc: - logger.warning( - "Primary Open Food Facts request failed; using fallback (%s)", - type(exc).__name__, + lambda: _governed_attempt(lambda: _fetch_product(safe_query)) ) + else: try: payload = await _OPEN_FOOD_FACTS_ADMISSION.run_attempt( - lambda: _governed_attempt(lambda: _fetch_fallback(params)) + lambda: _governed_attempt(lambda: _fetch_primary(params)) ) - except ValueError as fallback_exc: - logger.error( - "Open Food Facts fallback failed (%s)", - type(fallback_exc).__name__, + except httpx.HTTPStatusError: + # Do not bypass an upstream status (especially 429/503) through + # another transport. That would multiply load precisely when the + # source asks us to stop or is unavailable. + raise + except (httpx.RequestError, ValueError) as exc: + logger.warning( + "Primary Open Food Facts request failed; using fallback (%s)", + type(exc).__name__, ) - raise httpx.HTTPError( - f"Open Food Facts fallback failed: {fallback_exc}" - ) from fallback_exc + try: + payload = await _OPEN_FOOD_FACTS_ADMISSION.run_attempt( + lambda: _governed_attempt(lambda: _fetch_fallback(params)) + ) + except ValueError as fallback_exc: + logger.error( + "Open Food Facts fallback failed (%s)", + type(fallback_exc).__name__, + ) + raise httpx.HTTPError( + f"Open Food Facts fallback failed: {fallback_exc}" + ) from fallback_exc results = _normalize_products(payload) except httpx.HTTPStatusError as exc: @@ -216,25 +240,71 @@ async def _search_food_products_once( raise else: _OPEN_FOOD_FACTS_ADMISSION.record_success(permit) - _OPEN_FOOD_FACTS_AVAILABILITY.remember(safe_query, page_size, results) + _OPEN_FOOD_FACTS_AVAILABILITY.remember(safe_query, page_size, results, barcode=barcode) return results +def valid_food_barcode(value: str) -> str | None: + code = value.strip() + if not re.fullmatch(r"(?:[0-9]{8}|[0-9]{12}|[0-9]{13}|[0-9]{14})", code) or set(code) == {"0"}: + return None + total = sum(int(digit) * (3 if index % 2 == 0 else 1) + for index, digit in enumerate(reversed(code[:-1]))) + return code if (10 - total % 10) % 10 == int(code[-1]) else None + + +async def _fetch_product(code: str) -> dict[str, Any]: + """One exact, read-only OFF v3 product request. No image upload or redirects.""" + async with httpx.AsyncClient(timeout=_PRIMARY_TIMEOUT_SECONDS, follow_redirects=False) as client: + response = await client.get( + f"https://world.openfoodfacts.org/api/v3/product/{code}", + params={"fields": _OPEN_FOOD_FACTS_FIELDS + ",product_type", "product_type": "food"}, + headers=REQUEST_HEADERS, + ) + if response.status_code == 404: + return {"products": []} + response.raise_for_status() + try: + payload = response.json() + except ValueError as exc: + raise httpx.HTTPError("Invalid product response") from exc + if not isinstance(payload, dict) or payload.get("status") not in ("success", "success_with_warnings"): + raise httpx.HTTPError("Invalid product response") + product = payload.get("product") + if not isinstance(product, dict) or not isinstance(product.get("code"), str): + raise httpx.HTTPError("Invalid product record") + # OFF normalizes UPC/EAN leading zeros. Compare equivalent GTIN values + # as strings, rejecting any unrelated barcode or non-food result. + returned = valid_food_barcode(product["code"]) + if returned is None or returned.zfill(14) != code.zfill(14) or product.get("product_type", "food") != "food": + raise httpx.HTTPError("Product identity mismatch") + if not isinstance(product.get("product_name"), str) or not isinstance(product.get("nutriments"), dict): + return {"products": []} + return {"products": [product]} + + def _normalize_products(payload: dict[str, Any]) -> list[FoodSearchResult]: results: list[FoodSearchResult] = [] + products = payload.get("products", []) if isinstance(payload, dict) else None + if not isinstance(products, list): + raise httpx.HTTPError("Invalid Open Food Facts product list") nutrient_fields = { "calories": "energy-kcal", "protein": "proteins", "fat": "fat", "carbohydrates": "carbohydrates", } - for product in payload.get("products", []): - raw_product_name = (product.get("product_name") or "").strip() + for product in products: + if not isinstance(product, dict) or not isinstance(product.get("product_name"), str): + continue + raw_product_name = product["product_name"].strip() product_name = _repair_common_mojibake(raw_product_name) if not product_name: continue - nutriments = product.get("nutriments") or {} + nutriments = product.get("nutriments") + if not isinstance(nutriments, dict): + continue serving_size = _to_optional_text(product.get("serving_size")) nutrition = { name: _to_float(nutriments.get(f"{field}_serving")) @@ -260,8 +330,7 @@ def _normalize_products(payload: dict[str, Any]) -> list[FoodSearchResult]: if any(value is None for value in nutrition.values()): continue - results.append( - FoodSearchResult( + result = FoodSearchResult( product_name=product_name, calories=nutrition["calories"], protein=nutrition["protein"], @@ -272,30 +341,44 @@ def _normalize_products(payload: dict[str, Any]) -> list[FoodSearchResult]: brand=_extract_brand(product), serving_size=serving_size, nutri_score=_extract_nutri_score(product), - ) ) + try: + # Every offered result must fit the existing diary contract. Do not + # silently truncate a provider's product identity or source fields. + FoodLogCreate.model_validate(result.model_dump()) + except ValidationError: + continue + results.append(result) return results async def _fetch_primary(params: dict[str, Any]) -> dict[str, Any]: - """Make one primary Open Food Facts request; the caller owns fallback policy.""" - async with httpx.AsyncClient(timeout=_PRIMARY_TIMEOUT_SECONDS) as client: - response = await client.get( - OPEN_FOOD_FACTS_SEARCH_URL, - params=params, + """Use OFF's indexed full-text API; keep the bounded legacy transport fallback. + + https://openfoodfacts.github.io/search-a-licious/users/ref-openapi/ + Search is a read operation. POST keeps the query out of upstream access URLs. + """ + # The app accepts product names, not Lucene filters or wildcard expressions. + # Escape reserved syntax while preserving separate words and Unicode text. + query = re.sub(r'([+\-=&|> dict[str, Any]: diff --git a/backend/tests/test_account_profile.py b/backend/tests/test_account_profile.py new file mode 100644 index 00000000..2beeb7d1 --- /dev/null +++ b/backend/tests/test_account_profile.py @@ -0,0 +1,134 @@ +"""Private profile persistence, account isolation and authenticated widget reads.""" +import hashlib +import hmac +import json +from datetime import UTC, datetime, timedelta +from secrets import token_urlsafe + +import pytest +from sqlmodel import Session, create_engine + +import app.database as database +import app.main as main +from app.models import AuthSessionDB, CalorieAppUserDB, ExternalIdentityDB +from app.schema_migrations import upgrade_database, assert_database_at_head + +HEADERS = {'X-CalorieApp-Request': 'account-profile'} + +def save(client, value, user_id=None, headers=HEADERS): + user_id = user_id or client.get('/api/identity/me').json()['user_id'] + return client.post('/api/identity/profile', headers=headers, json={'user_id': user_id, 'nickname': value}) + +def session_for(client, user_id): + token = token_urlsafe(48) + now = datetime.now(UTC) + with Session(database.engine) as db: + db.add(AuthSessionDB(calorieapp_user_id=user_id, session_token_hash=hashlib.sha256(token.encode()).hexdigest(), created_at=now, last_seen_at=now, expires_at=now+timedelta(hours=1))) + db.commit() + client.cookies.clear() + client.cookies.set('calorieapp_session', token) + + +def test_nickname_survives_logout_and_fresh_session(authenticated_client): + c = authenticated_client + uid = c.get('/api/identity/me').json()['user_id'] + response = save(c, ' Piet ') + assert response.status_code == 200 + assert response.json()['nickname'] == 'Piet' + assert response.headers['cache-control'] == 'no-store' + assert c.post('/api/identity/logout').status_code == 200 + assert c.get('/api/identity/me').status_code == 401 + session_for(c, uid) + assert c.get('/api/identity/me').json()['nickname'] == 'Piet' + assert c.get('/api/identity/export').json()['account']['nickname'] == 'Piet' + assert save(c, None).json()['nickname'] is None + session_for(c, uid) + assert c.get('/api/identity/me').json()['nickname'] is None + + +def test_account_switch_cannot_read_or_overwrite_previous_nickname(authenticated_client): + c=authenticated_client + original=c.get('/api/identity/me').json()['user_id'] + assert save(c,'Piet').status_code == 200 + with Session(database.engine) as db: + other=CalorieAppUserDB();db.add(other);db.commit();db.refresh(other);other_id=other.id + session_for(c,other_id) + assert c.get('/api/identity/me').json()['nickname'] is None + assert save(c,'Changed',original).status_code == 409 + assert save(c,'Another').status_code == 200 + session_for(c,original) + assert c.get('/api/identity/me').json()['nickname'] == 'Piet' + + +@pytest.mark.parametrize('value',['A','a'*33,'`); +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/check_legal_boundaries.py b/tools/check_legal_boundaries.py index d2cbd196..c492bcb4 100644 --- a/tools/check_legal_boundaries.py +++ b/tools/check_legal_boundaries.py @@ -56,6 +56,8 @@ def main() -> None: "THIRD_PARTY_NOTICES.md", "ASSET_PROVENANCE.md", "IP_CLEARANCE.md", + "docs/ECOSYSTEM_OPEN_STEWARDSHIP_AND_FUNDING.md", + "contracts/ecosystem/v2/open-stewardship-and-funding.json", "contracts/identity-bridge/v1/code-provenance.json", "contracts/identity-bridge/v1/evidence/xummlogin-public-1.3.0-similarity.json", "contracts/identity-bridge/v1/evidence/xummlogin-live-1.3.1-similarity.json", @@ -73,10 +75,30 @@ def main() -> None: ("NO GENERAL LICENCE GRANTED", "GPL-2.0-or-later", "no permission is granted", "PATENTS AND PUBLIC DISCLOSURE"), ) require_text("COPYRIGHT.md", ("ICTHendrikse", "technical provenance", "GPL-2.0-or-later")) - require_text("TRADEMARKS.md", ("Pieter Hendrikse", "019137415", "019125433", "No repository licence grants")) + require_text("TRADEMARKS.md", ("Pieter Hendrikse", "019137415", "019125433", "No repository licence grants", "CalorieStudio")) require_text("DATA_LICENSING.md", ("Open Database License", "share-alike")) require_text("THIRD_PARTY_NOTICES.md", ("software bill of materials", "GPL-2.0-or-later")) require_text("IP_CLEARANCE.md", ("general ideas such as calorie tracking", "freedom-to-operate")) + require_text( + "docs/ECOSYSTEM_OPEN_STEWARDSHIP_AND_FUNDING.md", + ("existing repository is not automatically relicensed", "Founder/developer recovery", "No fixed percentages are declared yet", "CalorieStudio"), + ) + stewardship = require_json_object( + json.loads( + (ROOT / "contracts" / "ecosystem" / "v2" / "open-stewardship-and-funding.json").read_text(encoding="utf-8") + ), + "Open stewardship and funding contract", + ) + open_building = require_json_object(stewardship.get("open_building"), "open stewardship open_building") + if open_building.get("existing_repository_automatically_relicensed") is not False: + raise SystemExit("Open ecosystem policy must not silently relicense the existing repository") + commercial = require_json_object(stewardship.get("commercial_sustainability"), "open stewardship commercial_sustainability") + if commercial.get("founder_cost_recovery_recognised") is not True: + raise SystemExit("Sustainability model must retain founder cost recovery") + if commercial.get("ecosystem_treasury_long_term_funding_goal") is not True: + raise SystemExit("Sustainability model must retain long-term ecosystem funding") + if commercial.get("fixed_revenue_percentages_set") is not False: + raise SystemExit("Revenue percentages require separate explicit approval") require_text( "wordpress-plugins/calorieapp-identity-bridge/calorieapp-identity-bridge.php", ("License: GPL-2.0-or-later",), diff --git a/tools/gameverse_character_identity.py b/tools/gameverse_character_identity.py new file mode 100644 index 00000000..ebee4835 --- /dev/null +++ b/tools/gameverse_character_identity.py @@ -0,0 +1,93 @@ +"""Stable Gameverse starter-character identity compatibility model. + +The logical starter-character identity is deliberately separated from render +assets and infrastructure versions so scaling cannot silently replace it. +""" +from __future__ import annotations + +from dataclasses import dataclass, replace +import re + + +_ID = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}") + + +@dataclass(frozen=True) +class StarterCharacterIdentity: + starter_character_id: str + nickname_ref: str + progress_ref: str + unlocked_routes_ref: str + identity_revision: int = 1 + + def __post_init__(self) -> None: + if _ID.fullmatch(self.starter_character_id) is None: + raise ValueError("invalid-starter-character-id") + if type(self.identity_revision) is not int or self.identity_revision < 1: + raise ValueError("invalid-identity-revision") + + +@dataclass(frozen=True) +class CharacterRenderBinding: + starter_character_id: str + render_asset_key: str + render_model_version: str + compatibility_alias_used: bool = False + + +def bind_render( + identity: StarterCharacterIdentity, + *, + render_model_version: str, + available_assets: set[str], + compatibility_aliases: dict[str, str] | None = None, +) -> CharacterRenderBinding: + """Select a render asset without changing the stable character identity.""" + aliases = compatibility_aliases or {} + stable_id = identity.starter_character_id + if stable_id in available_assets: + asset = stable_id + alias_used = False + else: + asset = aliases.get(stable_id, "") + if asset not in available_assets: + raise ValueError("starter-character-render-unavailable") + alias_used = True + + return CharacterRenderBinding( + starter_character_id=stable_id, + render_asset_key=asset, + render_model_version=render_model_version, + compatibility_alias_used=alias_used, + ) + + +def upgrade_infrastructure( + identity: StarterCharacterIdentity, + *, + network_phase: str, +) -> StarterCharacterIdentity: + """Infrastructure scaling is identity-neutral by contract.""" + if not network_phase: + raise ValueError("network-phase-required") + return identity + + +def explicit_character_switch( + identity: StarterCharacterIdentity, + *, + new_starter_character_id: str, + user_confirmed: bool, +) -> StarterCharacterIdentity: + """Only an explicit user choice can change the stable character identity.""" + if user_confirmed is not True: + raise ValueError("explicit-user-confirmation-required") + if _ID.fullmatch(new_starter_character_id) is None: + raise ValueError("invalid-starter-character-id") + if new_starter_character_id == identity.starter_character_id: + return identity + return replace( + identity, + starter_character_id=new_starter_character_id, + identity_revision=identity.identity_revision + 1, + ) diff --git a/tools/participation_compute_simulator.py b/tools/participation_compute_simulator.py new file mode 100644 index 00000000..6392f930 --- /dev/null +++ b/tools/participation_compute_simulator.py @@ -0,0 +1,328 @@ +"""Bounded deterministic compute slice for the local participation simulator. + +This is not a general-purpose remote execution service. Only embedded synthetic +public fixtures are accepted and the worker is fixed repository code. +""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +import hashlib +import hmac +import json +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import time +from typing import Callable + +from tools.participation_simulator import Consent, FIXTURES, SPEC, SimulationError, canonical + + +ROOT = Path(__file__).resolve().parents[1] +COMPUTE = SPEC["compute"] +DAY = 86400 + + +def _digest(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +@dataclass(frozen=True) +class ComputeTask: + schema_version: str + task_type: str + task_id: str + participant_id: str + fixture: str + input_sha256: str + input_bytes: int + issued_at: int + expires_at: int + consent_revision: int + + +@dataclass(frozen=True) +class ComputeProof: + task_id: str + output: dict + output_sha256: str + + +@dataclass(frozen=True) +class ComputeReceipt: + status: str + verified: bool = False + credited_units: int = 0 + unit: str = "CALT_SIMULATED" + + +def _payload(name: str) -> bytes: + if name not in FIXTURES: + raise SimulationError("synthetic-fixture-required") + value = FIXTURES[name] + if len(value) > COMPUTE["max_input_bytes"]: + raise SimulationError("compute-input-budget-exceeded") + parsed = json.loads(value) + if len(parsed["records"]) > COMPUTE["max_records_per_task"]: + raise SimulationError("compute-record-budget-exceeded") + return value + + +def _run_fixed_worker(payload: bytes) -> dict: + try: + result = subprocess.run( + [sys.executable, "-I", str(ROOT / "tools" / "participation_compute_worker.py")], + input=payload, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=ROOT, + timeout=COMPUTE["worker_timeout_seconds"], + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise SimulationError("compute-time-budget-exceeded") from exc + if result.returncode != 0: + raise SimulationError("compute-worker-rejected-input") + if len(result.stdout) > COMPUTE["max_output_bytes"]: + raise SimulationError("compute-output-budget-exceeded") + try: + output = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise SimulationError("compute-output-invalid") from exc + if type(output) is not dict: + raise SimulationError("compute-output-invalid") + return output + + +class ComputeNode: + """One opted-in local helper; no arbitrary command or input admission.""" + + def __init__(self, *, enabled: bool = False, consent: Consent = Consent(), + clock: Callable[[], float] = time.time) -> None: + if enabled is not True: + raise SimulationError("simulation-disabled") + self.consent = consent + self.clock = clock + self.completed_tasks = 0 + + def execute(self, task: ComputeTask, *, now: int) -> ComputeProof: + if not self.consent.compute or self.consent.is_paused(now): + raise SimulationError("compute-not-consented-or-paused") + if self.completed_tasks >= COMPUTE["max_tasks_per_node_run"]: + raise SimulationError("compute-task-budget-exceeded") + if task.task_type != COMPUTE["task_type"]: + raise SimulationError("unsupported-compute-task") + if not task.issued_at <= now < task.expires_at: + raise SimulationError("compute-task-outside-window") + payload = _payload(task.fixture) + if task.input_sha256 != _digest(payload) or task.input_bytes != len(payload): + raise SimulationError("compute-input-integrity-failed") + output = _run_fixed_worker(payload) + encoded = canonical(output) + if len(encoded) > COMPUTE["max_output_bytes"]: + raise SimulationError("compute-output-budget-exceeded") + self.completed_tasks += 1 + return ComputeProof(task.task_id, output, _digest(encoded)) + + +class ComputeCoordinator: + """Separate synthetic compute ledger with independent central recomputation.""" + + def __init__(self, database: Path, *, enabled: bool = False, + clock: Callable[[], float] = time.time) -> None: + if enabled is not True: + raise SimulationError("simulation-disabled") + self.database = database + self.clock = clock + import sqlite3 + with sqlite3.connect(database) as db: + db.executescript(""" + CREATE TABLE IF NOT EXISTS participants ( + id TEXT PRIMARY KEY, age_band TEXT NOT NULL, + consent TEXT NOT NULL, revision INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, participant_id TEXT NOT NULL, + task TEXT NOT NULL, state TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS credits ( + participant_id TEXT NOT NULL, input_digest TEXT NOT NULL, + window_start INTEGER NOT NULL, task_id TEXT NOT NULL UNIQUE, + units INTEGER NOT NULL CHECK(units > 0), + PRIMARY KEY(participant_id, input_digest, window_start)); + """) + + def _connect(self): + import sqlite3 + db = sqlite3.connect(self.database, isolation_level=None, timeout=10) + db.row_factory = sqlite3.Row + return db + + def register(self, participant_id: str, *, age_band: str) -> None: + if re.fullmatch(r"sim-[a-z0-9-]{1,40}", participant_id) is None: + raise SimulationError("synthetic-identity-required") + if age_band not in ("child", "teen", "adult"): + raise SimulationError("invalid-age-band") + db = self._connect() + try: + db.execute("INSERT INTO participants VALUES (?, ?, ?, 0)", ( + participant_id, age_band, canonical(asdict(Consent())).decode("ascii"))) + finally: + db.close() + + def set_consent(self, participant_id: str, consent: Consent) -> None: + if not isinstance(consent, Consent): + raise SimulationError("invalid-consent") + db = self._connect() + try: + row = db.execute("SELECT 1 FROM participants WHERE id=?", (participant_id,)).fetchone() + if row is None: + raise SimulationError("unknown-participant") + db.execute("UPDATE participants SET consent=?, revision=revision+1 WHERE id=?", ( + canonical(asdict(consent)).decode("ascii"), participant_id)) + finally: + db.close() + + def issue(self, participant_id: str, fixture: str) -> ComputeTask: + import secrets + payload = _payload(fixture) + now = int(self.clock()) + db = self._connect() + try: + db.execute("BEGIN IMMEDIATE") + row = db.execute("SELECT * FROM participants WHERE id=?", (participant_id,)).fetchone() + if row is None: + raise SimulationError("unknown-participant") + consent = Consent(**json.loads(row["consent"])) + if not consent.compute or consent.is_paused(now): + raise SimulationError("compute-not-consented-or-paused") + expires = min(now + SPEC["challenge_ttl_seconds"], (now // DAY + 1) * DAY) + task = ComputeTask( + SPEC["schema_version"], COMPUTE["task_type"], "sim-" + secrets.token_hex(16), + participant_id, fixture, _digest(payload), len(payload), now, expires, row["revision"]) + db.execute("INSERT INTO tasks VALUES (?, ?, ?, 'pending')", ( + task.task_id, participant_id, canonical(asdict(task)).decode("ascii"))) + db.execute("COMMIT") + return task + except BaseException: + if db.in_transaction: + db.execute("ROLLBACK") + raise + finally: + db.close() + + def verify(self, participant_id: str, proof: ComputeProof) -> ComputeReceipt: + db = self._connect() + try: + db.execute("BEGIN IMMEDIATE") + row = db.execute("SELECT * FROM tasks WHERE id=?", (proof.task_id,)).fetchone() + if row is None or row["participant_id"] != participant_id: + db.execute("ROLLBACK") + return ComputeReceipt("unknown-task-or-owner") + if row["state"] != "pending": + db.execute("ROLLBACK") + return ComputeReceipt("already-consumed") + task = ComputeTask(**json.loads(row["task"])) + participant = db.execute( + "SELECT * FROM participants WHERE id=?", (participant_id,)).fetchone() + consent = Consent(**json.loads(participant["consent"])) + now = int(self.clock()) + status = None + if (not consent.compute or consent.is_paused(now) + or participant["revision"] != task.consent_revision): + status = "consent-changed-or-paused" + elif not task.issued_at <= now < task.expires_at: + status = "compute-task-outside-window" + else: + expected = _run_fixed_worker(_payload(task.fixture)) + encoded = canonical(proof.output) + if (len(encoded) > COMPUTE["max_output_bytes"] + or not hmac.compare_digest(proof.output_sha256, _digest(encoded)) + or proof.output != expected): + status = "invalid-compute-result" + db.execute("UPDATE tasks SET state=? WHERE id=?", ( + "rejected" if status else "verified", proof.task_id)) + if status: + db.execute("COMMIT") + return ComputeReceipt(status) + if participant["age_band"] != "adult" or not consent.rewards: + db.execute("COMMIT") + return ComputeReceipt("verified-without-reward", verified=True) + + window = task.issued_at // DAY * DAY + duplicate = db.execute( + "SELECT 1 FROM credits WHERE participant_id=? AND input_digest=? AND window_start=?", + (participant_id, task.input_sha256, window)).fetchone() + if duplicate: + db.execute("COMMIT") + return ComputeReceipt("verified-already-rewarded", verified=True) + total = db.execute( + "SELECT COALESCE(SUM(units),0) FROM credits WHERE participant_id=? AND window_start=?", + (participant_id, window)).fetchone()[0] + remaining = SPEC["reward"]["max_units_per_participant_per_utc_day"] - total + if remaining <= 0: + db.execute("COMMIT") + return ComputeReceipt("verified-reward-cap-reached", verified=True) + record_count = len(json.loads(_payload(task.fixture))["records"]) + quantum = COMPUTE["records_per_simulated_unit"] + work_units = max(1, (record_count + quantum - 1) // quantum) + amount = min(work_units, remaining) + db.execute("INSERT INTO credits VALUES (?, ?, ?, ?, ?)", ( + participant_id, task.input_sha256, window, task.task_id, amount)) + db.execute("COMMIT") + return ComputeReceipt( + "verified-and-credited" if amount == work_units else "verified-and-credited-capped", + verified=True, credited_units=amount) + except BaseException: + if db.in_transaction: + db.execute("ROLLBACK") + raise + finally: + db.close() + + def balance(self, participant_id: str) -> int: + db = self._connect() + try: + return db.execute( + "SELECT COALESCE(SUM(units),0) FROM credits WHERE participant_id=?", + (participant_id,)).fetchone()[0] + finally: + db.close() + + +def run_compute_demo(*, enabled: bool = False) -> dict: + if enabled is not True: + raise SimulationError("simulation-disabled") + now = 12 * DAY + 3600 + clock = lambda: now + with tempfile.TemporaryDirectory(prefix="calorie-compute-synthetic-") as directory: + coordinator = ComputeCoordinator(Path(directory) / "compute.sqlite", enabled=True, clock=clock) + consent = Consent(compute=True, rewards=True) + coordinator.register("sim-compute", age_band="adult") + coordinator.set_consent("sim-compute", consent) + node = ComputeNode(enabled=True, consent=consent, clock=clock) + task = coordinator.issue("sim-compute", "vegetable-catalog") + proof = node.execute(task, now=now) + receipt = coordinator.verify("sim-compute", proof) + return { + "mode": "local-synthetic-compute-only", + "task_type": COMPUTE["task_type"], + "verified": receipt.verified, + "credited_units": receipt.credited_units, + "unit": receipt.unit, + "balance": coordinator.balance("sim-compute"), + "onchain_transactions": 0, + "arbitrary_code_execution": False, + "secure_general_purpose_sandbox": COMPUTE["secure_general_purpose_sandbox"], + } + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--enable-synthetic-demo", action="store_true") + args = parser.parse_args() + if not args.enable_synthetic_demo: + print(json.dumps({"mode": "local-synthetic-compute-only", "status": "disabled"})) + raise SystemExit(2) + print(json.dumps(run_compute_demo(enabled=True), sort_keys=True)) diff --git a/tools/participation_compute_worker.py b/tools/participation_compute_worker.py new file mode 100644 index 00000000..0c9c9b72 --- /dev/null +++ b/tools/participation_compute_worker.py @@ -0,0 +1,61 @@ +"""Fixed synthetic compute worker. + +Reads one canonical JSON object from stdin and writes one canonical JSON object. +This module intentionally accepts no code, command, path, URL, database, wallet, +secret or network parameter. It is only a child process used by the local +participation simulator. +""" +from __future__ import annotations + +import json +import sys + + +def canonical(value: object) -> str: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False + ) + + +def summarize(payload: dict) -> dict: + if type(payload) is not dict or set(payload) != {"schema_version", "synthetic", "records"}: + raise ValueError("invalid-input") + if payload["schema_version"] != "caloriedb.synthetic-public-shard.v1": + raise ValueError("invalid-input") + if payload["synthetic"] is not True or type(payload["records"]) is not list: + raise ValueError("invalid-input") + + labels: list[str] = [] + ids: list[str] = [] + for record in payload["records"]: + if type(record) is not dict or set(record) != {"id", "label"}: + raise ValueError("invalid-record") + record_id = record["id"] + label = record["label"] + if type(record_id) is not str or type(label) is not str: + raise ValueError("invalid-record") + if len(record_id) > 80 or len(label) > 120: + raise ValueError("invalid-record") + ids.append(record_id) + labels.append(label) + + return { + "schema_version": "caloriedb.synthetic-compute-result.v1", + "record_count": len(ids), + "ids_sorted": sorted(ids), + "label_initials_sorted": sorted(label[:1].lower() for label in labels), + } + + +def main() -> int: + try: + payload = json.load(sys.stdin) + sys.stdout.write(canonical(summarize(payload)) + "\n") + return 0 + except (ValueError, TypeError, json.JSONDecodeError): + sys.stdout.write(canonical({"status": "invalid-input"}) + "\n") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/participation_data_release_simulator.py b/tools/participation_data_release_simulator.py new file mode 100644 index 00000000..6a90ccb0 --- /dev/null +++ b/tools/participation_data_release_simulator.py @@ -0,0 +1,117 @@ +"""Synthetic storage release and safe-handoff simulator. + +Models what happens to already assigned public/synthetic shards when a volunteer +pauses, stops, or exits storage participation. No private user data, remote +networking, real nodes, or token settlement are enabled. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +ReleaseMode = Literal["keep-local", "handoff-then-delete", "delete-now"] + + +@dataclass(frozen=True) +class ShardState: + shard_id: str + local_copy: bool = True + healthy_remote_replicas: int = 0 + hosted_copy_available: bool = True + + +@dataclass(frozen=True) +class ReleaseResult: + shard_id: str + mode: ReleaseMode + accept_new_storage_work: bool + local_copy_after: bool + safe_handoff_verified: bool + fallback_used: bool + remote_replicas_after: int + status: str + + +class StorageReleaseCoordinator: + """Deterministic synthetic release policy with participant-first control.""" + + def __init__(self, *, desired_remote_replicas: int = 3) -> None: + if type(desired_remote_replicas) is not int or desired_remote_replicas < 0: + raise ValueError("invalid-replica-target") + self.desired_remote_replicas = desired_remote_replicas + + def release(self, shard: ShardState, mode: ReleaseMode) -> ReleaseResult: + if mode not in {"keep-local", "handoff-then-delete", "delete-now"}: + raise ValueError("invalid-release-mode") + + if mode == "keep-local": + return ReleaseResult( + shard_id=shard.shard_id, + mode=mode, + accept_new_storage_work=False, + local_copy_after=shard.local_copy, + safe_handoff_verified=False, + fallback_used=False, + remote_replicas_after=shard.healthy_remote_replicas, + status="stopped-keeping-local-copy", + ) + + if mode == "delete-now": + # A participant always retains the right to remove their own local + # copy immediately. Availability is absorbed by the hosted fallback + # in this early architecture rather than pressuring the participant. + return ReleaseResult( + shard_id=shard.shard_id, + mode=mode, + accept_new_storage_work=False, + local_copy_after=False, + safe_handoff_verified=shard.hosted_copy_available + or shard.healthy_remote_replicas >= self.desired_remote_replicas, + fallback_used=( + shard.hosted_copy_available + and shard.healthy_remote_replicas < self.desired_remote_replicas + ), + remote_replicas_after=shard.healthy_remote_replicas, + status="local-copy-deleted-by-user-choice", + ) + + # handoff-then-delete + enough_remote = shard.healthy_remote_replicas >= self.desired_remote_replicas + safe = enough_remote or shard.hosted_copy_available + if not safe: + return ReleaseResult( + shard_id=shard.shard_id, + mode=mode, + accept_new_storage_work=False, + local_copy_after=shard.local_copy, + safe_handoff_verified=False, + fallback_used=False, + remote_replicas_after=shard.healthy_remote_replicas, + status="handoff-pending-keep-local-copy", + ) + + return ReleaseResult( + shard_id=shard.shard_id, + mode=mode, + accept_new_storage_work=False, + local_copy_after=False, + safe_handoff_verified=True, + fallback_used=not enough_remote and shard.hosted_copy_available, + remote_replicas_after=shard.healthy_remote_replicas, + status="handoff-verified-local-copy-released", + ) + + +def demo_release_matrix() -> list[dict]: + coordinator = StorageReleaseCoordinator(desired_remote_replicas=3) + cases = [ + ShardState("sim-a", healthy_remote_replicas=0, hosted_copy_available=True), + ShardState("sim-b", healthy_remote_replicas=3, hosted_copy_available=False), + ShardState("sim-c", healthy_remote_replicas=1, hosted_copy_available=False), + ] + results = [] + for shard in cases: + for mode in ("keep-local", "handoff-then-delete", "delete-now"): + results.append(coordinator.release(shard, mode).__dict__) + return results diff --git a/tools/participation_growth_simulator.py b/tools/participation_growth_simulator.py new file mode 100644 index 00000000..a8953bbf --- /dev/null +++ b/tools/participation_growth_simulator.py @@ -0,0 +1,105 @@ +"""Synthetic participation growth simulator. + +Models capacity growth from zero volunteers upward while preserving the hosted +fallback. No real nodes, remote execution, networking, storage, or token +settlement are enabled by this model. +""" +from __future__ import annotations + +from dataclasses import dataclass, asdict +import json + + +@dataclass(frozen=True) +class CapacityPolicy: + hosted_storage_units: int = 100 + hosted_compute_units: int = 100 + storage_units_per_node: int = 4 + compute_units_per_node: int = 3 + max_storage_units_per_node: int = 8 + max_compute_units_per_node: int = 6 + fallback_floor_percent: int = 20 + + +@dataclass(frozen=True) +class GrowthSnapshot: + volunteer_nodes: int + volunteer_storage_units: int + volunteer_compute_units: int + hosted_storage_units: int + hosted_compute_units: int + total_storage_units: int + total_compute_units: int + hosted_fallback_active: bool + app_available: bool + gameverse_available: bool + volunteer_storage_share_percent: int + volunteer_compute_share_percent: int + + +class GrowthModel: + def __init__(self, policy: CapacityPolicy = CapacityPolicy()) -> None: + self.policy = policy + + @staticmethod + def _checked_nodes(volunteer_nodes: int) -> int: + if type(volunteer_nodes) is not int or volunteer_nodes < 0: + raise ValueError("volunteer-nodes-must-be-nonnegative-integer") + return volunteer_nodes + + def snapshot( + self, + volunteer_nodes: int, + *, + storage_units_per_node: int | None = None, + compute_units_per_node: int | None = None, + ) -> GrowthSnapshot: + nodes = self._checked_nodes(volunteer_nodes) + storage_rate = ( + self.policy.storage_units_per_node + if storage_units_per_node is None else storage_units_per_node + ) + compute_rate = ( + self.policy.compute_units_per_node + if compute_units_per_node is None else compute_units_per_node + ) + if not 0 <= storage_rate <= self.policy.max_storage_units_per_node: + raise ValueError("storage-rate-out-of-range") + if not 0 <= compute_rate <= self.policy.max_compute_units_per_node: + raise ValueError("compute-rate-out-of-range") + + volunteer_storage = nodes * storage_rate + volunteer_compute = nodes * compute_rate + + # The first release keeps the hosted core fully available. A future + # policy may taper it, but never below the configured fallback floor. + hosted_storage = self.policy.hosted_storage_units + hosted_compute = self.policy.hosted_compute_units + + total_storage = hosted_storage + volunteer_storage + total_compute = hosted_compute + volunteer_compute + storage_share = 0 if total_storage == 0 else round(volunteer_storage * 100 / total_storage) + compute_share = 0 if total_compute == 0 else round(volunteer_compute * 100 / total_compute) + + return GrowthSnapshot( + volunteer_nodes=nodes, + volunteer_storage_units=volunteer_storage, + volunteer_compute_units=volunteer_compute, + hosted_storage_units=hosted_storage, + hosted_compute_units=hosted_compute, + total_storage_units=total_storage, + total_compute_units=total_compute, + hosted_fallback_active=True, + app_available=True, + gameverse_available=True, + volunteer_storage_share_percent=storage_share, + volunteer_compute_share_percent=compute_share, + ) + + def matrix(self) -> list[GrowthSnapshot]: + return [self.snapshot(nodes) for nodes in (0, 1, 10, 100, 1000)] + + +if __name__ == "__main__": + model = GrowthModel() + print(json.dumps([asdict(item) for item in model.matrix()], sort_keys=True)) diff --git a/tools/participation_reliability_simulator.py b/tools/participation_reliability_simulator.py new file mode 100644 index 00000000..44af6bdd --- /dev/null +++ b/tools/participation_reliability_simulator.py @@ -0,0 +1,133 @@ +"""Synthetic volunteer-node reliability and task assignment model. + +Models churn, pause/resume, dropout and rejoin behavior without enabling any real +remote execution, participant storage, networking or token settlement. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + + +@dataclass(frozen=True) +class NodeState: + node_id: str + storage: bool = False + compute: bool = False + paused: bool = False + online: bool = True + storage_capacity: int = 1 + compute_capacity: int = 1 + + def can_store(self) -> bool: + return self.online and not self.paused and self.storage and self.storage_capacity > 0 + + def can_compute(self) -> bool: + return self.online and not self.paused and self.compute and self.compute_capacity > 0 + + +@dataclass(frozen=True) +class Assignment: + task_id: str + task_type: str + node_id: str | None + fallback: bool + reason: str + + +class ReliabilityScheduler: + """Simple deterministic scheduler with safe hosted fallback.""" + + def __init__(self) -> None: + self._storage_cursor = 0 + self._compute_cursor = 0 + + @staticmethod + def _eligible(nodes: Iterable[NodeState], task_type: str) -> list[NodeState]: + if task_type == "storage": + return [n for n in nodes if n.can_store()] + if task_type == "compute": + return [n for n in nodes if n.can_compute()] + raise ValueError("unsupported-task-type") + + def assign(self, task_id: str, task_type: str, nodes: Iterable[NodeState]) -> Assignment: + eligible = sorted(self._eligible(nodes, task_type), key=lambda n: n.node_id) + if not eligible: + return Assignment( + task_id=task_id, + task_type=task_type, + node_id=None, + fallback=True, + reason="hosted-core-fallback", + ) + + if task_type == "storage": + index = self._storage_cursor % len(eligible) + self._storage_cursor += 1 + else: + index = self._compute_cursor % len(eligible) + self._compute_cursor += 1 + + chosen = eligible[index] + return Assignment( + task_id=task_id, + task_type=task_type, + node_id=chosen.node_id, + fallback=False, + reason="volunteer-node", + ) + + def assign_replica_set( + self, + task_id: str, + nodes: Iterable[NodeState], + desired_replicas: int, + ) -> tuple[list[str], int]: + if desired_replicas < 0: + raise ValueError("desired-replicas-must-be-nonnegative") + eligible = sorted(self._eligible(nodes, "storage"), key=lambda n: n.node_id) + selected = [n.node_id for n in eligible[:desired_replicas]] + missing = max(0, desired_replicas - len(selected)) + return selected, missing + + +def lifecycle_scenario() -> dict: + scheduler = ReliabilityScheduler() + nodes = [ + NodeState("sim-a", storage=True, compute=True), + NodeState("sim-b", storage=True, compute=False), + ] + + first = scheduler.assign("task-1", "compute", nodes) + + paused = [ + NodeState("sim-a", storage=True, compute=True, paused=True), + NodeState("sim-b", storage=True, compute=False), + ] + second = scheduler.assign("task-2", "compute", paused) + + offline = [ + NodeState("sim-a", storage=True, compute=True, online=False), + NodeState("sim-b", storage=True, compute=False), + ] + third = scheduler.assign("task-3", "storage", offline) + + rejoined = [ + NodeState("sim-a", storage=True, compute=True, online=True), + NodeState("sim-b", storage=True, compute=False, online=True), + ] + fourth = scheduler.assign("task-4", "compute", rejoined) + + replicas, missing = scheduler.assign_replica_set("blob-1", rejoined, 3) + + return { + "initial_compute": first.__dict__, + "paused_compute": second.__dict__, + "storage_while_compute_node_offline": third.__dict__, + "rejoined_compute": fourth.__dict__, + "replica_nodes": replicas, + "replica_shortfall": missing, + "hosted_core_always_available": True, + "real_network_enabled": False, + "real_token_settlement": False, + } diff --git a/tools/participation_resource_policy.py b/tools/participation_resource_policy.py new file mode 100644 index 00000000..1db1c4e5 --- /dev/null +++ b/tools/participation_resource_policy.py @@ -0,0 +1,117 @@ +"""Synthetic participant resource-policy model. + +This module models user-selected device and network limits for the optional +participation layer. It does not inspect a real device, network interface, +battery, idle state, or operating-system scheduler. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from tools.participation_simulator import SPEC, SimulationError + + +STORAGE_TASK = SPEC["task_type"] +COMPUTE_TASK = SPEC["compute"]["task_type"] +SUPPORTED_TASK_CLASSES = frozenset({STORAGE_TASK, COMPUTE_TASK}) +MIB = 1024 * 1024 + + +@dataclass(frozen=True) +class ResourcePreferences: + monthly_bandwidth_limit_mb: int = 500 + compute_limit_percent: int = 25 + wifi_only: bool = True + allow_battery: bool = False + idle_compute_only: bool = True + auto_start: bool = False + allowed_task_classes: frozenset[str] = SUPPORTED_TASK_CLASSES + + def __post_init__(self) -> None: + if type(self.monthly_bandwidth_limit_mb) is not int: + raise SimulationError("invalid-monthly-bandwidth-limit") + if not 100 <= self.monthly_bandwidth_limit_mb <= 10000: + raise SimulationError("invalid-monthly-bandwidth-limit") + if type(self.compute_limit_percent) is not int or not 5 <= self.compute_limit_percent <= 75: + raise SimulationError("invalid-compute-limit") + if any(type(value) is not bool for value in ( + self.wifi_only, + self.allow_battery, + self.idle_compute_only, + self.auto_start, + )): + raise SimulationError("invalid-resource-preference") + if type(self.allowed_task_classes) is not frozenset: + raise SimulationError("invalid-task-class-selection") + if not self.allowed_task_classes.issubset(SUPPORTED_TASK_CLASSES): + raise SimulationError("unsupported-task-class") + + +@dataclass(frozen=True) +class DeviceConditions: + on_wifi: bool = True + on_battery: bool = False + idle: bool = True + + def __post_init__(self) -> None: + if any(type(value) is not bool for value in ( + self.on_wifi, + self.on_battery, + self.idle, + )): + raise SimulationError("invalid-device-condition") + + +@dataclass(frozen=True) +class ResourceUsage: + monthly_transfer_bytes: int = 0 + + def __post_init__(self) -> None: + if type(self.monthly_transfer_bytes) is not int or self.monthly_transfer_bytes < 0: + raise SimulationError("invalid-transfer-usage") + + +@dataclass(frozen=True) +class TaskAdmission: + allowed: bool + reason: str + projected_monthly_transfer_bytes: int + compute_limit_percent: int + + +def admit_task( + preferences: ResourcePreferences, + conditions: DeviceConditions, + usage: ResourceUsage, + *, + task_type: str, + estimated_transfer_bytes: int, + manual_start: bool, +) -> TaskAdmission: + if type(estimated_transfer_bytes) is not int or estimated_transfer_bytes < 0: + raise SimulationError("invalid-estimated-transfer") + if type(manual_start) is not bool: + raise SimulationError("invalid-manual-start") + + projected = usage.monthly_transfer_bytes + estimated_transfer_bytes + base = { + "projected_monthly_transfer_bytes": projected, + "compute_limit_percent": preferences.compute_limit_percent, + } + + if task_type not in SUPPORTED_TASK_CLASSES: + return TaskAdmission(False, "unsupported-task-class", **base) + if task_type not in preferences.allowed_task_classes: + return TaskAdmission(False, "task-class-not-selected", **base) + if not manual_start and not preferences.auto_start: + return TaskAdmission(False, "manual-start-required", **base) + if preferences.wifi_only and not conditions.on_wifi: + return TaskAdmission(False, "wifi-required", **base) + if conditions.on_battery and not preferences.allow_battery: + return TaskAdmission(False, "battery-use-disabled", **base) + if task_type == COMPUTE_TASK and preferences.idle_compute_only and not conditions.idle: + return TaskAdmission(False, "idle-compute-required", **base) + if projected > preferences.monthly_bandwidth_limit_mb * MIB: + return TaskAdmission(False, "monthly-bandwidth-cap-reached", **base) + + return TaskAdmission(True, "allowed-within-user-limits", **base) diff --git a/tools/participation_simulator.py b/tools/participation_simulator.py new file mode 100644 index 00000000..96afde27 --- /dev/null +++ b/tools/participation_simulator.py @@ -0,0 +1,537 @@ +"""Local, synthetic storage challenge/response drill. No service or wallet access. + +Run from the repository root: + python -m tools.participation_simulator --enable-synthetic-demo + +The coordinator and node have separate storage but run on one trusted machine. +This is a protocol/credit-accounting simulator, not proof of durable storage, +independent operators, authenticated users, or a production-ready network. +""" + +from __future__ import annotations + +import argparse +from contextlib import contextmanager +from dataclasses import asdict, dataclass, replace +import hashlib +import hmac +import json +from pathlib import Path +import re +import secrets +import sqlite3 +import tempfile +import time +from typing import Callable, Iterator + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = json.loads( + (ROOT / "contracts/participation/v1/simulator.json").read_text(encoding="utf-8") +) +DAY = 86400 + + +class SimulationError(ValueError): + """A bounded rejection code without user data or local paths.""" + + +def canonical(value: object) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=True, allow_nan=False).encode("ascii") + + +def digest(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +# Deliberately invented records, without personal data or imported OFF/USDA data. +# There is no external-file, URL, database, or arbitrary-input admission path. +FIXTURES = { + name: canonical({ + "schema_version": "caloriedb.synthetic-public-shard.v1", + "synthetic": True, + "records": [{"id": "synthetic-" + name, "label": "Demo " + name}], + }) + for name in ("apple", "bread", "water") +} +FIXTURES["vegetable-catalog"] = canonical({ + "schema_version": "caloriedb.synthetic-public-shard.v1", + "synthetic": True, + "records": [ + {"id": "synthetic-" + name, "label": "Demo " + name} + for name in ("carrot", "broccoli", "spinach", "pepper", "cabbage") + ], +}) +PAYLOADS = {digest(value): value for value in FIXTURES.values()} + + +def _integer(value: int, minimum: int, maximum: int) -> bool: + return type(value) is int and minimum <= value <= maximum + + +def _enabled(enabled: bool) -> None: + if enabled is not True: + raise SimulationError("simulation-disabled") + + +@dataclass(frozen=True) +class Consent: + storage: bool = False + compute: bool = False + rewards: bool = False + paused: bool = False + pause_until: int | None = None + storage_limit_bytes: int = 4096 + transfer_limit_bytes: int = 8192 + + def __post_init__(self) -> None: + if any(type(value) is not bool for value in + (self.storage, self.compute, self.rewards, self.paused)): + raise SimulationError("invalid-consent") + if not _integer(self.storage_limit_bytes, 0, SPEC["max_storage_bytes"]): + raise SimulationError("invalid-storage-limit") + if not _integer(self.transfer_limit_bytes, 0, SPEC["max_transfer_bytes_per_run"]): + raise SimulationError("invalid-transfer-limit") + if self.pause_until is not None and ( + not self.paused or not _integer(self.pause_until, 0, 2**53 - 1)): + raise SimulationError("invalid-pause-until") + + def is_paused(self, now: int) -> bool: + return self.paused and (self.pause_until is None or now < self.pause_until) + + +def _pause(consent: Consent, until: int | None, now: int) -> Consent: + if not consent.storage and not consent.compute: + raise SimulationError("participation-not-consented") + if until is not None and (type(until) is not int or until <= now): + raise SimulationError("pause-end-must-be-in-the-future") + return replace(consent, paused=True, pause_until=until) + + +def _stop(consent: Consent) -> Consent: + return replace(consent, storage=False, compute=False, rewards=False, + paused=False, pause_until=None) + + +@dataclass(frozen=True) +class Manifest: + schema_version: str + classification: str + sha256: str + byte_length: int + + +def manifest_for(name: str) -> Manifest: + if name not in FIXTURES: + raise SimulationError("synthetic-fixture-required") + payload = FIXTURES[name] + return Manifest("caloriedb.synthetic-public-shard.v1", "synthetic-public", + digest(payload), len(payload)) + + +def _payload(manifest: Manifest) -> bytes: + value = PAYLOADS.get(manifest.sha256) + if (value is None or manifest.schema_version != "caloriedb.synthetic-public-shard.v1" + or manifest.classification != "synthetic-public" + or type(manifest.byte_length) is not int or manifest.byte_length != len(value)): + raise SimulationError("synthetic-fixture-required") + return value + + +@dataclass(frozen=True) +class Challenge: + schema_version: str + task_type: str + challenge_id: str + participant_id: str + shard: Manifest + nonce: str + issued_at: int + expires_at: int + consent_revision: int + + +@dataclass(frozen=True) +class Proof: + challenge_id: str + response_sha256: str + + +@dataclass(frozen=True) +class Receipt: + status: str + verified: bool = False + credited_units: int = 0 + unit: str = "CALT_SIMULATED" + + +def storage_response(challenge: Challenge, stored_bytes: bytes) -> str: + # The response uses the actual bytes, not just the published shard digest. + # Canonical, domain-separated framing binds nonce, task, owner and revision. + return digest(SPEC["proof_domain"].encode("ascii") + b"\0" + + canonical(asdict(challenge)) + b"\0" + stored_bytes) + + +class VolunteerNode: + """Single-process node with explicit consent and per-run byte budgets.""" + + def __init__(self, directory: Path, *, enabled: bool = False, + consent: Consent = Consent(), clock: Callable[[], float] = time.time) -> None: + _enabled(enabled) + self.directory = directory + self.consent = consent + self.clock = clock + self.transferred_bytes = 0 + directory.mkdir(parents=True, exist_ok=True) + + def _active(self, now: int | None = None) -> None: + if not self.consent.storage or self.consent.is_paused( + int(self.clock()) if now is None else now): + raise SimulationError("storage-not-consented-or-paused") + + def pause(self, *, until: int | None = None) -> None: + self.consent = _pause(self.consent, until, int(self.clock())) + + def resume(self) -> None: + self.consent = replace(self.consent, paused=False, pause_until=None) + + def stop(self, *, delete_local_shards: bool = True) -> None: + self.consent = _stop(self.consent) + if delete_local_shards: + for name in FIXTURES: + self.remove(manifest_for(name)) + + def _path(self, manifest: Manifest) -> Path: + _payload(manifest) + path = self.directory / (manifest.sha256 + ".shard") + if path.is_symlink(): + raise SimulationError("invalid-shard-file") + return path + + def _stored_bytes(self) -> int: + size = 0 + for shard_digest in PAYLOADS: + path = self.directory / (shard_digest + ".shard") + if path.is_symlink(): + raise SimulationError("invalid-shard-file") + if path.exists(): + size += path.stat().st_size + return size + + def _transfer(self, byte_count: int) -> None: + if self.transferred_bytes + byte_count > self.consent.transfer_limit_bytes: + raise SimulationError("transfer-budget-exceeded") + + def store(self, manifest: Manifest, supplied_bytes: bytes) -> None: + self._active() + expected = _payload(manifest) + if type(supplied_bytes) is not bytes or supplied_bytes != expected: + raise SimulationError("shard-integrity-failed") + path = self._path(manifest) + additional = 0 if path.exists() else len(supplied_bytes) + if self._stored_bytes() + additional > self.consent.storage_limit_bytes: + raise SimulationError("storage-budget-exceeded") + self._transfer(len(supplied_bytes)) + if path.exists(): + self._read(manifest) + else: + with path.open("xb") as handle: + handle.write(supplied_bytes) + self.transferred_bytes += len(supplied_bytes) + + def _read(self, manifest: Manifest) -> bytes: + path = self._path(manifest) + try: + with path.open("rb") as handle: + value = handle.read(manifest.byte_length + 1) + except FileNotFoundError: + raise SimulationError("shard-missing") from None + if len(value) != manifest.byte_length or digest(value) != manifest.sha256: + raise SimulationError("shard-integrity-failed") + return value + + def prove(self, challenge: Challenge, *, now: int) -> Proof: + self._active(now) + if (challenge.schema_version != SPEC["schema_version"] + or challenge.task_type != SPEC["task_type"]): + raise SimulationError("unsupported-challenge") + if not challenge.issued_at <= now < challenge.expires_at: + raise SimulationError("challenge-outside-window") + if self._stored_bytes() > self.consent.storage_limit_bytes: + raise SimulationError("storage-budget-exceeded") + value = self._read(challenge.shard) + proof = Proof(challenge.challenge_id, storage_response(challenge, value)) + cost = len(canonical(asdict(proof))) + self._transfer(cost) + self.transferred_bytes += cost + return proof + + def remove(self, manifest: Manifest) -> None: + """Explicit local cleanup remains available after pause/withdrawal.""" + self._path(manifest).unlink(missing_ok=True) + + +class Coordinator: + """Synthetic identities and atomic off-chain receipts in a separate SQLite file. + + The caller's participant_id models trusted authentication context. It is not + authentication. Never expose these methods as network endpoints unchanged. + """ + + def __init__(self, database: Path, *, enabled: bool = False, + clock: Callable[[], float] = time.time) -> None: + _enabled(enabled) + self.database = database + self.clock = clock + with self._connection() as db: + db.executescript(""" + CREATE TABLE IF NOT EXISTS participants ( + id TEXT PRIMARY KEY, age_band TEXT NOT NULL, + consent TEXT NOT NULL, revision INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS challenges ( + id TEXT PRIMARY KEY, participant_id TEXT NOT NULL, + challenge TEXT NOT NULL, state TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS credits ( + participant_id TEXT NOT NULL, shard_digest TEXT NOT NULL, + window_start INTEGER NOT NULL, challenge_id TEXT NOT NULL UNIQUE, + units INTEGER NOT NULL CHECK(units > 0), + PRIMARY KEY(participant_id, shard_digest, window_start)); + """) + + @contextmanager + def _connection(self) -> Iterator[sqlite3.Connection]: + db = sqlite3.connect(self.database, isolation_level=None, timeout=10) + db.row_factory = sqlite3.Row + try: + yield db + finally: + db.close() + + @contextmanager + def _transaction(self) -> Iterator[sqlite3.Connection]: + with self._connection() as db: + db.execute("BEGIN IMMEDIATE") + try: + yield db + db.execute("COMMIT") + except BaseException: + db.execute("ROLLBACK") + raise + + def _now(self) -> int: + now = int(self.clock()) + if now < 0: + raise SimulationError("invalid-clock") + return now + + @staticmethod + def _participant(db: sqlite3.Connection, participant_id: str) -> sqlite3.Row: + row = db.execute("SELECT * FROM participants WHERE id=?", (participant_id,)).fetchone() + if row is None: + raise SimulationError("unknown-participant") + return row + + def register(self, participant_id: str, *, age_band: str) -> None: + if not re.fullmatch(r"sim-[a-z0-9-]{1,40}", participant_id): + raise SimulationError("synthetic-identity-required") + if age_band not in ("child", "teen", "adult"): + raise SimulationError("invalid-age-band") + with self._transaction() as db: + db.execute("INSERT INTO participants VALUES (?, ?, ?, 0)", + (participant_id, age_band, canonical(asdict(Consent())).decode("ascii"))) + + def set_consent(self, participant_id: str, consent: Consent) -> None: + if not isinstance(consent, Consent): + raise SimulationError("invalid-consent") + self._change_consent(participant_id, lambda previous: consent) + + def _change_consent(self, participant_id: str, + change: Callable[[Consent], Consent]) -> None: + with self._transaction() as db: + participant = self._participant(db, participant_id) + consent = change(Consent(**json.loads(participant["consent"]))) + # Every change invalidates pending challenges, including pause/resume + # and withdraw/re-opt-in. Previously earned receipts remain unchanged. + db.execute("UPDATE participants SET consent=?, revision=revision+1 WHERE id=?", + (canonical(asdict(consent)).decode("ascii"), participant_id)) + + def pause(self, participant_id: str, *, until: int | None = None) -> None: + self._change_consent(participant_id, lambda consent: _pause(consent, until, self._now())) + + def resume(self, participant_id: str) -> None: + self._change_consent(participant_id, lambda consent: replace( + consent, paused=False, pause_until=None)) + + def stop(self, participant_id: str) -> None: + self._change_consent(participant_id, _stop) + + def issue(self, participant_id: str, fixture: str) -> Challenge: + manifest = manifest_for(fixture) + with self._transaction() as db: + row = self._participant(db, participant_id) + consent = Consent(**json.loads(row["consent"])) + now = self._now() + if not consent.storage or consent.is_paused(now): + raise SimulationError("storage-not-consented-or-paused") + if manifest.byte_length > consent.storage_limit_bytes: + raise SimulationError("storage-budget-exceeded") + if manifest.byte_length > consent.transfer_limit_bytes: + raise SimulationError("transfer-budget-exceeded") + expires = min(now + SPEC["challenge_ttl_seconds"], (now // DAY + 1) * DAY) + challenge = Challenge( + SPEC["schema_version"], SPEC["task_type"], "sim-" + secrets.token_hex(16), + participant_id, manifest, secrets.token_hex(32), now, expires, row["revision"], + ) + db.execute("INSERT INTO challenges VALUES (?, ?, ?, 'pending')", + (challenge.challenge_id, participant_id, + canonical(asdict(challenge)).decode("ascii"))) + return challenge + + def verify(self, participant_id: str, proof: Proof) -> Receipt: + # Owner, consent, one-time consumption, deduplication and cap are checked + # under one write lock, including concurrent submissions/restarts. + with self._transaction() as db: + row = db.execute("SELECT * FROM challenges WHERE id=?", (proof.challenge_id,)).fetchone() + if row is None or row["participant_id"] != participant_id: + return Receipt("unknown-challenge-or-owner") + if row["state"] != "pending": + return Receipt("already-consumed") + saved = json.loads(row["challenge"]) + saved["shard"] = Manifest(**saved["shard"]) + challenge = Challenge(**saved) + participant = self._participant(db, participant_id) + consent = Consent(**json.loads(participant["consent"])) + now = self._now() + status = None + if (not consent.storage or consent.is_paused(now) + or participant["revision"] != challenge.consent_revision): + status = "consent-changed-or-paused" + elif not challenge.issued_at <= now < challenge.expires_at: + status = "challenge-outside-window" + elif (type(proof.response_sha256) is not str + or re.fullmatch(r"[0-9a-f]{64}", proof.response_sha256) is None + or not hmac.compare_digest( + proof.response_sha256, storage_response(challenge, _payload(challenge.shard)))): + status = "invalid-proof" + db.execute("UPDATE challenges SET state=? WHERE id=?", + ("rejected" if status else "verified", proof.challenge_id)) + if status: + return Receipt(status) + if participant["age_band"] != "adult" or not consent.rewards: + return Receipt("verified-without-reward", verified=True) + window = challenge.issued_at // DAY * DAY + existing = db.execute( + "SELECT 1 FROM credits WHERE participant_id=? AND shard_digest=? AND window_start=?", + (participant_id, challenge.shard.sha256, window), + ).fetchone() + if existing: + return Receipt("verified-already-rewarded", verified=True) + total = db.execute( + "SELECT COALESCE(SUM(units), 0) FROM credits WHERE participant_id=? AND window_start=?", + (participant_id, window), + ).fetchone()[0] + remaining = SPEC["reward"]["max_units_per_participant_per_utc_day"] - total + if remaining <= 0: + return Receipt("verified-reward-cap-reached", verified=True) + quantum = SPEC["reward"]["bytes_per_simulated_unit"] + work_units = (challenge.shard.byte_length + quantum - 1) // quantum + amount = min(work_units, remaining) + db.execute("INSERT INTO credits VALUES (?, ?, ?, ?, ?)", + (participant_id, challenge.shard.sha256, window, proof.challenge_id, amount)) + status = "verified-and-credited" if amount == work_units else "verified-and-credited-capped" + return Receipt(status, verified=True, credited_units=amount) + + def balance(self, participant_id: str) -> int: + with self._connection() as db: + self._participant(db, participant_id) + return db.execute("SELECT COALESCE(SUM(units), 0) FROM credits WHERE participant_id=?", + (participant_id,)).fetchone()[0] + + +def run_demo(*, enabled: bool = False) -> dict: + _enabled(enabled) + with tempfile.TemporaryDirectory(prefix="calorie-participation-synthetic-") as directory: + root = Path(directory) + # A fixed synthetic clock keeps the demonstration reproducible even at + # a real UTC-day boundary. Expiry/day rollover have separate tests. + demo_time = 10 * DAY + 3600 + coordinator = Coordinator(root / "coordinator.sqlite3", enabled=True, + clock=lambda: demo_time) + consent = Consent(storage=True, rewards=True) + coordinator.register("sim-adult", age_band="adult") + coordinator.set_consent("sim-adult", consent) + node = VolunteerNode(root / "node", enabled=True, consent=consent, + clock=lambda: demo_time) + challenge = coordinator.issue("sim-adult", "apple") + node.store(challenge.shard, FIXTURES["apple"]) + proof = node.prove(challenge, now=coordinator._now()) + accepted = coordinator.verify("sim-adult", proof) + replay = coordinator.verify("sim-adult", proof) + duplicate = coordinator.issue("sim-adult", "apple") + duplicate_receipt = coordinator.verify( + "sim-adult", node.prove(duplicate, now=coordinator._now())) + pause_end = demo_time + 60 + node.pause(until=pause_end) + coordinator.pause("sim-adult", until=pause_end) + try: + node.prove(duplicate, now=demo_time) + paused_locally = False + except SimulationError as error: + paused_locally = str(error) == "storage-not-consented-or-paused" + demo_time = pause_end + resumed = coordinator.issue("sim-adult", "apple") + resumed_receipt = coordinator.verify( + "sim-adult", node.prove(resumed, now=demo_time)) + changed = coordinator.issue("sim-adult", "apple") + changed_proof = node.prove(changed, now=coordinator._now()) + coordinator.stop("sim-adult") + node.stop() + withdrawn = coordinator.verify("sim-adult", changed_proof) + coordinator.register("sim-larger-work", age_band="adult") + coordinator.set_consent("sim-larger-work", consent) + larger_node = VolunteerNode(root / "larger-node", enabled=True, consent=consent, + clock=lambda: demo_time) + larger = coordinator.issue("sim-larger-work", "vegetable-catalog") + larger_node.store(larger.shard, FIXTURES["vegetable-catalog"]) + larger_receipt = coordinator.verify( + "sim-larger-work", larger_node.prove(larger, now=demo_time)) + larger_node.stop() + coordinator.stop("sim-larger-work") + result = { + "schema_version": SPEC["schema_version"], + "mode": "local-synthetic-only", + "clock": "fixed-synthetic", + "stored_shard_sha256": challenge.shard.sha256, + "stored_shard_bytes": challenge.shard.byte_length, + "accepted": asdict(accepted), + "replay": asdict(replay), + "duplicate_work": asdict(duplicate_receipt), + "temporary_pause_blocked_local_work": paused_locally, + "fresh_work_after_selected_pause_end": asdict(resumed_receipt), + "withdrawn_consent": asdict(withdrawn), + "balance": coordinator.balance("sim-adult"), + "unit": SPEC["reward"]["unit"], + "reward_asset": "CALT", + "larger_verified_work": asdict(larger_receipt), + "larger_shard_bytes": larger.shard.byte_length, + "local_shard_removed": node._stored_bytes() == 0, + "onchain_transactions": 0, + } + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--enable-synthetic-demo", action="store_true", + help="Explicitly run the temporary, offline fixture demonstration.") + args = parser.parse_args() + if not args.enable_synthetic_demo: + print(json.dumps({"status": "disabled", "mode": "local-synthetic-only"})) + return 2 + print(json.dumps(run_demo(enabled=True), indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/participation_ui_adapter.py b/tools/participation_ui_adapter.py new file mode 100644 index 00000000..df93649f --- /dev/null +++ b/tools/participation_ui_adapter.py @@ -0,0 +1,444 @@ +"""Local synthetic participation UI state adapter. + +Bridges the Participation Lab prototype to separate synthetic storage/compute +simulators without enabling remote nodes, networking, token settlement or +arbitrary jobs. +""" +from __future__ import annotations + +from dataclasses import asdict, dataclass, replace +import json +from pathlib import Path +import tempfile +import time + +from tools.participation_compute_simulator import ComputeCoordinator, ComputeNode +from tools.participation_data_release_simulator import ( + ShardState, + StorageReleaseCoordinator, +) +from tools.participation_simulator import ( + Consent, + Coordinator, + FIXTURES, + SPEC, + SimulationError, + VolunteerNode, + manifest_for, +) + + +LIFECYCLE_STATES = {"off", "running", "paused"} +STORAGE_RELEASE_MODES = {"keep-local", "handoff-then-delete", "delete-now"} + + +@dataclass(frozen=True) +class ParticipationSelection: + storage: bool = False + compute: bool = False + rewards: bool = False + storage_limit_mb: int = 250 + compute_limit_percent: int = 25 + monthly_bandwidth_limit_mb: int = 500 + wifi_only: bool = True + allow_battery: bool = False + idle_compute_only: bool = True + auto_start: bool = False + allow_storage_tasks: bool = True + allow_compute_tasks: bool = True + storage_release_mode: str = "keep-local" + storage_state: str = "off" + process_state: str = "off" + + +@dataclass(frozen=True) +class DeviceContext: + on_wifi: bool = True + on_battery: bool = False + is_idle: bool = True + + +class ParticipationSession: + """Synthetic-only session with independent storage/compute lifecycles.""" + + def __init__(self, *, enabled: bool = False, age_band: str = "adult", + clock=time.time) -> None: + if enabled is not True: + raise SimulationError("simulation-disabled") + self.clock = clock + self.age_band = age_band + self.selection = ParticipationSelection() + self._tmp = tempfile.TemporaryDirectory(prefix="participation-ui-") + self.root = Path(self._tmp.name) + self.storage_node_dir = self.root / "storage-node" + self.storage_coord = Coordinator( + self.root / "storage.sqlite", enabled=True, clock=clock + ) + self.compute_coord = ComputeCoordinator( + self.root / "compute.sqlite", enabled=True, clock=clock + ) + self.release_coord = StorageReleaseCoordinator(desired_remote_replicas=3) + self.bandwidth_used_bytes = 0 + self.participant_id = "sim-ui" + self.storage_coord.register(self.participant_id, age_band=age_band) + self.compute_coord.register(self.participant_id, age_band=age_band) + + def _storage_consent(self) -> Consent: + selected = self.selection.storage + state = self.selection.storage_state + return Consent( + storage=selected and state in {"running", "paused"}, + compute=False, + rewards=self.selection.rewards, + paused=selected and state == "paused", + storage_limit_bytes=SPEC["max_storage_bytes"], + transfer_limit_bytes=SPEC["max_transfer_bytes_per_run"], + ) + + def _compute_consent(self) -> Consent: + selected = self.selection.compute + state = self.selection.process_state + return Consent( + storage=False, + compute=selected and state in {"running", "paused"}, + rewards=self.selection.rewards, + paused=selected and state == "paused", + ) + + def apply(self, selection: ParticipationSelection) -> dict: + if selection.storage_limit_mb < 50 or selection.storage_limit_mb > 2000: + raise SimulationError("storage-limit-out-of-range") + if selection.compute_limit_percent < 5 or selection.compute_limit_percent > 75: + raise SimulationError("compute-limit-out-of-range") + if (type(selection.monthly_bandwidth_limit_mb) is not int + or selection.monthly_bandwidth_limit_mb < 100 + or selection.monthly_bandwidth_limit_mb > 10000): + raise SimulationError("bandwidth-limit-out-of-range") + if any(type(value) is not bool for value in ( + selection.wifi_only, + selection.allow_battery, + selection.idle_compute_only, + selection.auto_start, + selection.allow_storage_tasks, + selection.allow_compute_tasks, + )): + raise SimulationError("invalid-advanced-participation-setting") + if selection.storage_release_mode not in STORAGE_RELEASE_MODES: + raise SimulationError("invalid-storage-release-mode") + if selection.storage_state not in LIFECYCLE_STATES: + raise SimulationError("invalid-storage-state") + if selection.process_state not in LIFECYCLE_STATES: + raise SimulationError("invalid-process-state") + if selection.storage_state in {"running", "paused"} and not selection.storage: + raise SimulationError("storage-must-be-enabled-before-process") + if selection.process_state in {"running", "paused"} and not selection.compute: + raise SimulationError("compute-must-be-enabled-before-process") + + self.selection = selection + self.storage_coord.set_consent(self.participant_id, self._storage_consent()) + self.compute_coord.set_consent(self.participant_id, self._compute_consent()) + return self.snapshot() + + def _bandwidth_limit_bytes(self) -> int: + return self.selection.monthly_bandwidth_limit_mb * 1024 * 1024 + + def _check_runtime_policy(self, task_type: str, context: DeviceContext) -> None: + if not isinstance(context, DeviceContext): + raise SimulationError("invalid-device-context") + if self.bandwidth_used_bytes >= self._bandwidth_limit_bytes(): + raise SimulationError("monthly-bandwidth-cap-reached") + if self.selection.wifi_only and not context.on_wifi: + raise SimulationError("wifi-required") + if not self.selection.allow_battery and context.on_battery: + raise SimulationError("battery-participation-disabled") + if task_type == "storage": + if not self.selection.allow_storage_tasks: + raise SimulationError("storage-task-class-disabled") + elif task_type == "compute": + if not self.selection.allow_compute_tasks: + raise SimulationError("compute-task-class-disabled") + if self.selection.idle_compute_only and not context.is_idle: + raise SimulationError("compute-requires-idle-device") + else: + raise SimulationError("unsupported-participation-task-type") + + def auto_start_preview(self, context: DeviceContext = DeviceContext()) -> dict: + """Model future auto-start eligibility without starting background work.""" + eligible: list[str] = [] + blocked: dict[str, str] = {} + if not self.selection.auto_start: + return { + "auto_start_opted_in": False, + "eligible": eligible, + "blocked": blocked, + "state_changed": False, + } + + for task_type, selected in ( + ("storage", self.selection.storage), + ("compute", self.selection.compute), + ): + if not selected: + continue + try: + self._check_runtime_policy(task_type, context) + except SimulationError as error: + blocked[task_type] = str(error) + else: + eligible.append(task_type) + return { + "auto_start_opted_in": True, + "eligible": eligible, + "blocked": blocked, + "state_changed": False, + } + + def _local_fixture_shards(self) -> list[tuple[str, Path]]: + shards = [] + for fixture in FIXTURES: + manifest = manifest_for(fixture) + path = self.storage_node_dir / (manifest.sha256 + ".shard") + if path.exists(): + shards.append((manifest.sha256, path)) + return shards + + def snapshot(self) -> dict: + return { + "mode": "local-synthetic-ui-adapter", + "community_volunteer_nodes": 0, + "hosted_core_active": True, + "normal_app_access": True, + "normal_gameverse_access": True, + "selection": asdict(self.selection), + "effective_storage_consent": asdict(self._storage_consent()), + "effective_compute_consent": asdict(self._compute_consent()), + "retained_local_synthetic_shards": len(self._local_fixture_shards()), + "bandwidth_used_bytes": self.bandwidth_used_bytes, + "monthly_bandwidth_limit_bytes": self._bandwidth_limit_bytes(), + "real_network_enabled": False, + "real_token_settlement": False, + } + + def run_storage_probe(self, context: DeviceContext = DeviceContext()) -> dict: + if not self.selection.storage: + return {"status": "storage-off"} + if self.selection.storage_state == "paused": + return {"status": "storage-paused"} + if self.selection.storage_state != "running": + return {"status": "storage-stopped"} + self._check_runtime_policy("storage", context) + + consent = self._storage_consent() + node = VolunteerNode( + self.storage_node_dir, + enabled=True, + consent=consent, + clock=self.clock, + ) + challenge = self.storage_coord.issue(self.participant_id, "apple") + node.store(challenge.shard, FIXTURES["apple"]) + proof = node.prove(challenge, now=int(self.clock())) + receipt = self.storage_coord.verify(self.participant_id, proof) + self.bandwidth_used_bytes += node.transferred_bytes + return { + "status": receipt.status, + "verified": receipt.verified, + "synthetic_shard_retained_locally": True, + } + + def run_compute_probe(self, context: DeviceContext = DeviceContext()) -> dict: + if not self.selection.compute: + return {"status": "compute-off"} + if self.selection.process_state == "paused": + return {"status": "compute-paused"} + if self.selection.process_state != "running": + return {"status": "compute-stopped"} + self._check_runtime_policy("compute", context) + + consent = self._compute_consent() + node = ComputeNode(enabled=True, consent=consent, clock=self.clock) + task = self.compute_coord.issue(self.participant_id, "apple") + proof = node.execute(task, now=int(self.clock())) + receipt = self.compute_coord.verify(self.participant_id, proof) + output_bytes = len(json.dumps( + proof.output, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode("ascii")) + self.bandwidth_used_bytes += task.input_bytes + output_bytes + return {"status": receipt.status, "verified": receipt.verified} + + def release_existing_storage( + self, + *, + mode: str | None = None, + healthy_remote_replicas: int = 0, + hosted_copy_available: bool = True, + ) -> list[dict]: + release_mode = mode or self.selection.storage_release_mode + if release_mode not in STORAGE_RELEASE_MODES: + raise SimulationError("invalid-storage-release-mode") + if type(healthy_remote_replicas) is not int or healthy_remote_replicas < 0: + raise SimulationError("invalid-remote-replica-count") + + results = [] + for shard_id, path in self._local_fixture_shards(): + result = self.release_coord.release( + ShardState( + shard_id=shard_id, + local_copy=True, + healthy_remote_replicas=healthy_remote_replicas, + hosted_copy_available=hosted_copy_available, + ), + release_mode, + ) + if not result.local_copy_after: + path.unlink(missing_ok=True) + results.append(asdict(result)) + return results + + def stop_storage( + self, + *, + healthy_remote_replicas: int = 0, + hosted_copy_available: bool = True, + ) -> dict: + if self.selection.storage_state == "off": + raise SimulationError("storage-already-off") + self.selection = replace(self.selection, storage_state="off") + self.storage_coord.set_consent(self.participant_id, self._storage_consent()) + releases = self.release_existing_storage( + healthy_remote_replicas=healthy_remote_replicas, + hosted_copy_available=hosted_copy_available, + ) + snapshot = self.snapshot() + snapshot["storage_release_results"] = releases + return snapshot + + def exit( + self, + *, + healthy_remote_replicas: int = 0, + hosted_copy_available: bool = True, + ) -> dict: + release_mode = self.selection.storage_release_mode + + # Revoke both contribution permissions before any cleanup/handoff work. + # Exiting can never leave a window in which new volunteer work is accepted. + self.selection = replace( + self.selection, + storage=False, + compute=False, + rewards=False, + storage_state="off", + process_state="off", + ) + self.storage_coord.set_consent(self.participant_id, Consent()) + self.compute_coord.set_consent(self.participant_id, Consent()) + + releases = self.release_existing_storage( + mode=release_mode, + healthy_remote_replicas=healthy_remote_replicas, + hosted_copy_available=hosted_copy_available, + ) + snapshot = self.snapshot() + snapshot["storage_release_results"] = releases + snapshot["exit_storage_release_mode"] = release_mode + return snapshot + + def close(self) -> None: + self._tmp.cleanup() + + +def full_lifecycle_demo() -> dict: + """Run one complete voluntary lifecycle without enabling a real network.""" + now = 45 * 86400 + 3600 + session = ParticipationSession(enabled=True, clock=lambda: now) + events: list[dict] = [] + try: + events.append({"step": "zero-participation", "snapshot": session.snapshot()}) + + opted_in = ParticipationSelection( + storage=True, + compute=True, + storage_release_mode="handoff-then-delete", + storage_state="off", + process_state="off", + ) + events.append({"step": "opt-in-no-auto-start", "snapshot": session.apply(opted_in)}) + + storage_running = replace(opted_in, storage_state="running") + session.apply(storage_running) + events.append({"step": "storage-started", "probe": session.run_storage_probe()}) + + both_running = replace(storage_running, process_state="running") + session.apply(both_running) + events.append({"step": "compute-started", "probe": session.run_compute_probe()}) + + compute_paused = replace(both_running, process_state="paused") + session.apply(compute_paused) + events.append({ + "step": "compute-paused-storage-continues", + "storage_probe": session.run_storage_probe(), + "compute_probe": session.run_compute_probe(), + }) + + session.apply(replace(compute_paused, process_state="running")) + stopped = session.stop_storage( + healthy_remote_replicas=0, + hosted_copy_available=True, + ) + events.append({ + "step": "storage-stopped-safe-handoff", + "snapshot": stopped, + "compute_probe": session.run_compute_probe(), + }) + + exited = session.exit( + healthy_remote_replicas=0, + hosted_copy_available=True, + ) + events.append({"step": "exit", "snapshot": exited}) + + return { + "mode": "local-synthetic-voluntary-lifecycle", + "events": events, + "real_network_enabled": False, + "real_token_settlement": False, + } + finally: + session.close() + + +def demo_matrix() -> list[dict]: + now = 30 * 86400 + 3600 + session = ParticipationSession(enabled=True, clock=lambda: now) + try: + cases = [ + ParticipationSelection(), + ParticipationSelection(storage=True, storage_state="off"), + ParticipationSelection(storage=True, storage_state="running"), + ParticipationSelection(storage=True, storage_state="paused"), + ParticipationSelection(compute=True, process_state="off"), + ParticipationSelection(compute=True, process_state="running"), + ParticipationSelection(compute=True, process_state="paused"), + ParticipationSelection( + storage=True, + compute=True, + rewards=True, + storage_state="running", + process_state="running", + ), + ] + results = [] + for case in cases: + snap = session.apply(case) + snap["storage_probe"] = session.run_storage_probe() + snap["compute_probe"] = session.run_compute_probe() + results.append(snap) + results.append(session.exit()) + return results + finally: + session.close() + + +if __name__ == "__main__": + print(json.dumps(demo_matrix(), sort_keys=True)) 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, /