From 3f0e3ed4ce83ed1b54d5293e6fc8c89dd039bb73 Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sun, 30 Aug 2026 06:04:22 +0200 Subject: [PATCH 1/4] Add Identity Bridge v1 locale contract foundation --- .github/workflows/ci.yml | 9 + README.md | 1 + backend/app/data/locales.json | 98 +++++++++++ backend/app/locales.py | 78 +++++++++ backend/tests/test_locales.py | 45 +++++ contracts/identity-bridge/v1/README.md | 35 ++++ contracts/identity-bridge/v1/locales.json | 98 +++++++++++ .../identity-bridge/v1/locales.schema.json | 52 ++++++ contracts/identity-bridge/v1/security.json | 53 ++++++ frontend/config/locales.json | 98 +++++++++++ frontend/lib/locales.ts | 64 +++++++ tools/build_wordpress_plugin_release.py | 2 +- tools/sync_identity_contracts.py | 165 ++++++++++++++++++ tools/tests/identity_locales.test.mjs | 64 +++++++ .../test_build_wordpress_plugin_release.py | 1 + tools/tests/test_identity_contracts.py | 55 ++++++ .../tests/wordpress_locale_registry.test.php | 29 +++ .../calorieapp-identity-bridge/README.md | 6 + .../calorieapp-identity-bridge.php | 4 +- .../config/locales.json | 98 +++++++++++ ...ieapp-identity-bridge-integrated-login.php | 2 +- ...rieapp-identity-bridge-locale-registry.php | 103 +++++++++++ .../class-calorieapp-identity-bridge.php | 1 + 23 files changed, 1157 insertions(+), 4 deletions(-) create mode 100644 backend/app/data/locales.json create mode 100644 backend/app/locales.py create mode 100644 backend/tests/test_locales.py create mode 100644 contracts/identity-bridge/v1/README.md create mode 100644 contracts/identity-bridge/v1/locales.json create mode 100644 contracts/identity-bridge/v1/locales.schema.json create mode 100644 contracts/identity-bridge/v1/security.json create mode 100644 frontend/config/locales.json create mode 100644 frontend/lib/locales.ts create mode 100644 tools/sync_identity_contracts.py create mode 100644 tools/tests/identity_locales.test.mjs create mode 100644 tools/tests/test_identity_contracts.py create mode 100644 tools/tests/wordpress_locale_registry.test.php create mode 100644 wordpress-plugins/calorieapp-identity-bridge/config/locales.json create mode 100644 wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-locale-registry.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76d4cc8..1e74c1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,11 @@ jobs: - name: Enforce legal and licensing boundaries run: python tools/check_legal_boundaries.py + - name: Validate Identity Bridge contracts + run: | + python tools/sync_identity_contracts.py --check + python -m unittest tools.tests.test_identity_contracts + wordpress-plugin-release-check: name: WordPress plugin release check runs-on: ubuntu-latest @@ -99,6 +104,9 @@ jobs: } PHP + - name: Test WordPress locale registry + run: php tools/tests/wordpress_locale_registry.test.php + - name: Set up Python uses: actions/setup-python@v7 with: @@ -176,6 +184,7 @@ jobs: run: >- node --test tools/tests/calorieapp_embed_readiness.test.mjs + tools/tests/identity_locales.test.mjs tools/tests/xaman_login_start_retry.test.mjs - name: Build frontend diff --git a/README.md b/README.md index 2cd9c41..b983e95 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ gate can additionally run the local developer health check. ## Documentation +- Versioned Identity Bridge contracts: contracts/identity-bridge/v1/ - Public architecture: docs/public/architecture.md - Public roadmap: docs/public/roadmap.md - Public deployment guide: docs/public/deployment.md diff --git a/backend/app/data/locales.json b/backend/app/data/locales.json new file mode 100644 index 0000000..a3bf88e --- /dev/null +++ b/backend/app/data/locales.json @@ -0,0 +1,98 @@ +{ + "$schema": "./locales.schema.json", + "contract_id": "gallery-token.locale-registry", + "contract_version": "1.0.0", + "source_locale": "en", + "fallback_locale": "en", + "selection_policy": "Top ten individual languages by total L1 plus L2 speakers, with Dutch as an additional fully supported locale.", + "locales": [ + { + "tag": "en", + "english_name": "English", + "native_name": "English", + "direction": "ltr", + "source": true, + "aliases": ["en-US", "en-GB"] + }, + { + "tag": "zh-Hans", + "english_name": "Mandarin Chinese (Simplified)", + "native_name": "简体中文", + "direction": "ltr", + "source": false, + "aliases": ["zh", "zh-CN", "zh-SG"] + }, + { + "tag": "hi", + "english_name": "Hindi", + "native_name": "हिन्दी", + "direction": "ltr", + "source": false, + "aliases": ["hi-IN"] + }, + { + "tag": "es", + "english_name": "Spanish", + "native_name": "Español", + "direction": "ltr", + "source": false, + "aliases": ["es-ES", "es-MX"] + }, + { + "tag": "ar", + "english_name": "Modern Standard Arabic", + "native_name": "العربية", + "direction": "rtl", + "source": false, + "aliases": ["ar-EG", "ar-SA"] + }, + { + "tag": "fr", + "english_name": "French", + "native_name": "Français", + "direction": "ltr", + "source": false, + "aliases": ["fr-FR", "fr-CA"] + }, + { + "tag": "bn", + "english_name": "Bengali", + "native_name": "বাংলা", + "direction": "ltr", + "source": false, + "aliases": ["bn-BD", "bn-IN"] + }, + { + "tag": "pt", + "english_name": "Portuguese", + "native_name": "Português", + "direction": "ltr", + "source": false, + "aliases": ["pt-BR", "pt-PT"] + }, + { + "tag": "id", + "english_name": "Indonesian", + "native_name": "Bahasa Indonesia", + "direction": "ltr", + "source": false, + "aliases": ["id-ID"] + }, + { + "tag": "ur", + "english_name": "Urdu", + "native_name": "اردو", + "direction": "rtl", + "source": false, + "aliases": ["ur-PK", "ur-IN"] + }, + { + "tag": "nl", + "english_name": "Dutch", + "native_name": "Nederlands", + "direction": "ltr", + "source": false, + "aliases": ["nl-NL", "nl-BE"] + } + ] +} diff --git a/backend/app/locales.py b/backend/app/locales.py new file mode 100644 index 0000000..281b1b7 --- /dev/null +++ b/backend/app/locales.py @@ -0,0 +1,78 @@ +"""Shared locale resolution for CalorieApp identity and UI surfaces.""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any, Iterator, Optional + + +REGISTRY_PATH = Path(__file__).with_name("data") / "locales.json" + + +@lru_cache(maxsize=1) +def locale_registry() -> dict[str, Any]: + """Load the deployable copy of the frozen v1 locale registry.""" + try: + value = json.loads(REGISTRY_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError("CalorieApp locale registry is unavailable") from exc + if not isinstance(value, dict) or not isinstance(value.get("locales"), list): + raise RuntimeError("CalorieApp locale registry is malformed") + return value + + +def supported_locale_tags() -> tuple[str, ...]: + return tuple(locale["tag"] for locale in locale_registry()["locales"]) + + +@lru_cache(maxsize=1) +def _locale_identifier_map() -> dict[str, str]: + identifiers: dict[str, str] = {} + for locale in locale_registry()["locales"]: + tag = locale["tag"] + for identifier in [tag, *locale.get("aliases", [])]: + identifiers[_normalize_identifier(identifier)] = tag + return identifiers + + +def _normalize_identifier(value: str) -> str: + return value.strip().replace("_", "-").lower() + + +def _requested_candidates(value: Optional[str]) -> Iterator[str]: + if not value: + return + for part in value.split(","): + candidate = part.split(";", 1)[0].strip() + if candidate and candidate != "*": + yield _normalize_identifier(candidate) + + +def resolve_locale(value: Optional[str]) -> str: + """Resolve a locale tag or Accept-Language-like value with English fallback.""" + registry = locale_registry() + identifiers = _locale_identifier_map() + canonical_primary_tags = { + locale["tag"].lower(): locale["tag"] + for locale in registry["locales"] + if "-" not in locale["tag"] + } + + for candidate in _requested_candidates(value): + exact = identifiers.get(candidate) + if exact: + return exact + primary = candidate.split("-", 1)[0] + if primary in canonical_primary_tags: + return canonical_primary_tags[primary] + return registry["fallback_locale"] + + +def locale_direction(value: Optional[str]) -> str: + resolved = resolve_locale(value) + for locale in locale_registry()["locales"]: + if locale["tag"] == resolved: + return locale["direction"] + return "ltr" diff --git a/backend/tests/test_locales.py b/backend/tests/test_locales.py new file mode 100644 index 0000000..2358e37 --- /dev/null +++ b/backend/tests/test_locales.py @@ -0,0 +1,45 @@ +from app.locales import locale_direction, locale_registry, resolve_locale, supported_locale_tags + + +EXPECTED_LOCALES = ( + "en", + "zh-Hans", + "hi", + "es", + "ar", + "fr", + "bn", + "pt", + "id", + "ur", + "nl", +) + + +def test_frozen_v1_locale_set_and_order() -> None: + assert supported_locale_tags() == EXPECTED_LOCALES + assert locale_registry()["source_locale"] == "en" + assert locale_registry()["fallback_locale"] == "en" + + +def test_locale_resolution_accepts_aliases_and_language_variants() -> None: + assert resolve_locale("zh-CN") == "zh-Hans" + assert resolve_locale("pt_BR") == "pt" + assert resolve_locale("es-AR") == "es" + assert resolve_locale("nl-BE") == "nl" + assert resolve_locale("fr-CH,fr;q=0.8,en;q=0.5") == "fr" + + +def test_unknown_or_unsupported_locale_falls_back_to_english() -> None: + assert resolve_locale(None) == "en" + assert resolve_locale("") == "en" + assert resolve_locale("de-DE") == "en" + assert resolve_locale("zh-Hant") == "en" + assert resolve_locale("*") == "en" + + +def test_only_arabic_and_urdu_are_right_to_left() -> None: + assert locale_direction("ar") == "rtl" + assert locale_direction("ur-PK") == "rtl" + assert locale_direction("en") == "ltr" + assert locale_direction("unknown") == "ltr" diff --git a/contracts/identity-bridge/v1/README.md b/contracts/identity-bridge/v1/README.md new file mode 100644 index 0000000..0e73696 --- /dev/null +++ b/contracts/identity-bridge/v1/README.md @@ -0,0 +1,35 @@ +# CalorieApp Identity Bridge contract v1 + +This directory is the repository source of truth for the first Identity Bridge +contract and the shared Gallery Token locale registry. Runtime copies are +generated for the backend, frontend and WordPress plugin because those three +artifacts are deployed and packaged independently. + +## Frozen v1 boundaries + +- WordPress owns the authenticated WordPress browser session. +- Xaman provides a server-verified proof; browser-supplied identity claims are + never authoritative. +- The CalorieApp backend owns its opaque application session and private food + log authorization. +- Login state, browser handoff and authorization codes are short-lived, + single-use and replay protected. +- Origins and callback URLs are explicit allowlists. HTTPS is mandatory outside + loopback-only local development. +- The v1 identity payload remains minimal. Optional names, email addresses, + donation details or public profile fields require a separate consent and + purpose contract before they may be added. + +## Locale contract + +English is the source and fallback locale. The registry contains the fixed set +of ten selected world languages plus Dutch. Arabic and Urdu are right-to-left. +All products must resolve unsupported or malformed locale input to English. + +Run `python tools/sync_identity_contracts.py` after changing the canonical +registry. CI uses `--check` and rejects drift between the source and the three +runtime copies. + +Localized external publishing remains a separate editorial workflow: +preview, review, explicit GO, scheduling and publishing. The locale registry +must not trigger automatic bulk posting. diff --git a/contracts/identity-bridge/v1/locales.json b/contracts/identity-bridge/v1/locales.json new file mode 100644 index 0000000..a3bf88e --- /dev/null +++ b/contracts/identity-bridge/v1/locales.json @@ -0,0 +1,98 @@ +{ + "$schema": "./locales.schema.json", + "contract_id": "gallery-token.locale-registry", + "contract_version": "1.0.0", + "source_locale": "en", + "fallback_locale": "en", + "selection_policy": "Top ten individual languages by total L1 plus L2 speakers, with Dutch as an additional fully supported locale.", + "locales": [ + { + "tag": "en", + "english_name": "English", + "native_name": "English", + "direction": "ltr", + "source": true, + "aliases": ["en-US", "en-GB"] + }, + { + "tag": "zh-Hans", + "english_name": "Mandarin Chinese (Simplified)", + "native_name": "简体中文", + "direction": "ltr", + "source": false, + "aliases": ["zh", "zh-CN", "zh-SG"] + }, + { + "tag": "hi", + "english_name": "Hindi", + "native_name": "हिन्दी", + "direction": "ltr", + "source": false, + "aliases": ["hi-IN"] + }, + { + "tag": "es", + "english_name": "Spanish", + "native_name": "Español", + "direction": "ltr", + "source": false, + "aliases": ["es-ES", "es-MX"] + }, + { + "tag": "ar", + "english_name": "Modern Standard Arabic", + "native_name": "العربية", + "direction": "rtl", + "source": false, + "aliases": ["ar-EG", "ar-SA"] + }, + { + "tag": "fr", + "english_name": "French", + "native_name": "Français", + "direction": "ltr", + "source": false, + "aliases": ["fr-FR", "fr-CA"] + }, + { + "tag": "bn", + "english_name": "Bengali", + "native_name": "বাংলা", + "direction": "ltr", + "source": false, + "aliases": ["bn-BD", "bn-IN"] + }, + { + "tag": "pt", + "english_name": "Portuguese", + "native_name": "Português", + "direction": "ltr", + "source": false, + "aliases": ["pt-BR", "pt-PT"] + }, + { + "tag": "id", + "english_name": "Indonesian", + "native_name": "Bahasa Indonesia", + "direction": "ltr", + "source": false, + "aliases": ["id-ID"] + }, + { + "tag": "ur", + "english_name": "Urdu", + "native_name": "اردو", + "direction": "rtl", + "source": false, + "aliases": ["ur-PK", "ur-IN"] + }, + { + "tag": "nl", + "english_name": "Dutch", + "native_name": "Nederlands", + "direction": "ltr", + "source": false, + "aliases": ["nl-NL", "nl-BE"] + } + ] +} diff --git a/contracts/identity-bridge/v1/locales.schema.json b/contracts/identity-bridge/v1/locales.schema.json new file mode 100644 index 0000000..ba6ece9 --- /dev/null +++ b/contracts/identity-bridge/v1/locales.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://calorietoken.net/contracts/identity-bridge/v1/locales.schema.json", + "title": "Gallery Token locale registry", + "type": "object", + "additionalProperties": false, + "required": [ + "contract_id", + "contract_version", + "source_locale", + "fallback_locale", + "selection_policy", + "locales" + ], + "properties": { + "$schema": { "type": "string" }, + "contract_id": { "const": "gallery-token.locale-registry" }, + "contract_version": { "type": "string", "pattern": "^[1-9][0-9]*\\.[0-9]+\\.[0-9]+$" }, + "source_locale": { "type": "string" }, + "fallback_locale": { "type": "string" }, + "selection_policy": { "type": "string", "minLength": 1 }, + "locales": { + "type": "array", + "minItems": 11, + "maxItems": 11, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "tag", + "english_name", + "native_name", + "direction", + "source", + "aliases" + ], + "properties": { + "tag": { "type": "string", "pattern": "^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?$" }, + "english_name": { "type": "string", "minLength": 1 }, + "native_name": { "type": "string", "minLength": 1 }, + "direction": { "enum": ["ltr", "rtl"] }, + "source": { "type": "boolean" }, + "aliases": { + "type": "array", + "items": { "type": "string", "minLength": 2 }, + "uniqueItems": true + } + } + } + } + } +} diff --git a/contracts/identity-bridge/v1/security.json b/contracts/identity-bridge/v1/security.json new file mode 100644 index 0000000..82cca9b --- /dev/null +++ b/contracts/identity-bridge/v1/security.json @@ -0,0 +1,53 @@ +{ + "contract_id": "calorieapp.identity-bridge.security", + "contract_version": "1.0.0", + "protocol_version": "v1", + "identity_authority": { + "wordpress_session_required": true, + "xaman_signature_verified_server_side": true, + "calorieapp_backend_issues_application_session": true, + "browser_supplied_identity_claims_authoritative": false + }, + "origin_policy": { + "explicit_allowlists": true, + "wildcards_allowed": false, + "https_required": true, + "http_exception": "loopback-only when CALORIEAPP_ENV=local" + }, + "login_state": { + "minimum_length": 32, + "maximum_length": 255, + "ttl_seconds": 300, + "single_use": true, + "replay_protected": true + }, + "authorization_code": { + "minimum_ttl_seconds": 10, + "default_ttl_seconds": 60, + "maximum_ttl_seconds": 300, + "single_use": true + }, + "integrated_login_flow": { + "ttl_seconds": 600, + "same_origin_start_and_finish": true + }, + "application_session": { + "absolute_ttl_seconds": 28800, + "idle_ttl_seconds": 1800, + "opaque_server_side_token": true, + "cookie_http_only": true, + "cookie_secure_outside_local": true, + "cookie_same_site_default": "lax" + }, + "identity_claims": { + "allowed": ["external_subject", "xrpl_address", "issued_at", "expires_at", "jti"], + "optional": ["xrpl_address"], + "personal_profile_fields_enabled_by_default": [] + }, + "localization": { + "registry": "locales.json", + "source_locale": "en", + "fallback_locale": "en", + "rtl_locales": ["ar", "ur"] + } +} diff --git a/frontend/config/locales.json b/frontend/config/locales.json new file mode 100644 index 0000000..a3bf88e --- /dev/null +++ b/frontend/config/locales.json @@ -0,0 +1,98 @@ +{ + "$schema": "./locales.schema.json", + "contract_id": "gallery-token.locale-registry", + "contract_version": "1.0.0", + "source_locale": "en", + "fallback_locale": "en", + "selection_policy": "Top ten individual languages by total L1 plus L2 speakers, with Dutch as an additional fully supported locale.", + "locales": [ + { + "tag": "en", + "english_name": "English", + "native_name": "English", + "direction": "ltr", + "source": true, + "aliases": ["en-US", "en-GB"] + }, + { + "tag": "zh-Hans", + "english_name": "Mandarin Chinese (Simplified)", + "native_name": "简体中文", + "direction": "ltr", + "source": false, + "aliases": ["zh", "zh-CN", "zh-SG"] + }, + { + "tag": "hi", + "english_name": "Hindi", + "native_name": "हिन्दी", + "direction": "ltr", + "source": false, + "aliases": ["hi-IN"] + }, + { + "tag": "es", + "english_name": "Spanish", + "native_name": "Español", + "direction": "ltr", + "source": false, + "aliases": ["es-ES", "es-MX"] + }, + { + "tag": "ar", + "english_name": "Modern Standard Arabic", + "native_name": "العربية", + "direction": "rtl", + "source": false, + "aliases": ["ar-EG", "ar-SA"] + }, + { + "tag": "fr", + "english_name": "French", + "native_name": "Français", + "direction": "ltr", + "source": false, + "aliases": ["fr-FR", "fr-CA"] + }, + { + "tag": "bn", + "english_name": "Bengali", + "native_name": "বাংলা", + "direction": "ltr", + "source": false, + "aliases": ["bn-BD", "bn-IN"] + }, + { + "tag": "pt", + "english_name": "Portuguese", + "native_name": "Português", + "direction": "ltr", + "source": false, + "aliases": ["pt-BR", "pt-PT"] + }, + { + "tag": "id", + "english_name": "Indonesian", + "native_name": "Bahasa Indonesia", + "direction": "ltr", + "source": false, + "aliases": ["id-ID"] + }, + { + "tag": "ur", + "english_name": "Urdu", + "native_name": "اردو", + "direction": "rtl", + "source": false, + "aliases": ["ur-PK", "ur-IN"] + }, + { + "tag": "nl", + "english_name": "Dutch", + "native_name": "Nederlands", + "direction": "ltr", + "source": false, + "aliases": ["nl-NL", "nl-BE"] + } + ] +} diff --git a/frontend/lib/locales.ts b/frontend/lib/locales.ts new file mode 100644 index 0000000..d2b6cc6 --- /dev/null +++ b/frontend/lib/locales.ts @@ -0,0 +1,64 @@ +import localeRegistry from "@/config/locales.json"; + +export type LocaleDirection = "ltr" | "rtl"; + +export type SupportedLocaleDefinition = { + tag: string; + english_name: string; + native_name: string; + direction: LocaleDirection; + source: boolean; + aliases: string[]; +}; + +export const sourceLocale = localeRegistry.source_locale; +export const fallbackLocale = localeRegistry.fallback_locale; +export const supportedLocales = + localeRegistry.locales as SupportedLocaleDefinition[]; + +const identifierMap = new Map(); +const canonicalPrimaryTags = new Map(); + +function normalizeIdentifier(value: string): string { + return value.trim().replace(/_/g, "-").toLowerCase(); +} + +for (const locale of supportedLocales) { + identifierMap.set(normalizeIdentifier(locale.tag), locale.tag); + for (const alias of locale.aliases) { + identifierMap.set(normalizeIdentifier(alias), locale.tag); + } + if (!locale.tag.includes("-")) { + canonicalPrimaryTags.set(locale.tag.toLowerCase(), locale.tag); + } +} + +export function resolveLocale(value?: string | null): string { + if (value) { + for (const part of value.split(",")) { + const rawCandidate = part.split(";", 1)[0]?.trim(); + if (!rawCandidate || rawCandidate === "*") { + continue; + } + const candidate = normalizeIdentifier(rawCandidate); + const exact = identifierMap.get(candidate); + if (exact) { + return exact; + } + const primary = candidate.split("-", 1)[0]; + const supportedPrimary = canonicalPrimaryTags.get(primary); + if (supportedPrimary) { + return supportedPrimary; + } + } + } + return fallbackLocale; +} + +export function localeDirection(value?: string | null): LocaleDirection { + const resolved = resolveLocale(value); + return ( + supportedLocales.find((locale) => locale.tag === resolved)?.direction ?? + "ltr" + ); +} diff --git a/tools/build_wordpress_plugin_release.py b/tools/build_wordpress_plugin_release.py index 414717b..6e7a845 100644 --- a/tools/build_wordpress_plugin_release.py +++ b/tools/build_wordpress_plugin_release.py @@ -24,7 +24,7 @@ "SECURITY.md", f"{PLUGIN_SLUG}.php", ) -RELEASE_GLOBS = ("includes/*.php", "assets/*.css", "assets/*.js") +RELEASE_GLOBS = ("includes/*.php", "assets/*.css", "assets/*.js", "config/*.json") FORBIDDEN_SUFFIXES = ( ".bak", ".db", diff --git a/tools/sync_identity_contracts.py b/tools/sync_identity_contracts.py new file mode 100644 index 0000000..39535c5 --- /dev/null +++ b/tools/sync_identity_contracts.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Validate and synchronize deployable Identity Bridge contract copies.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +CONTRACT_DIR = ROOT / "contracts" / "identity-bridge" / "v1" +CANONICAL_LOCALES = CONTRACT_DIR / "locales.json" +SECURITY_CONTRACT = CONTRACT_DIR / "security.json" +LOCALE_TARGETS = ( + ROOT / "backend" / "app" / "data" / "locales.json", + ROOT / "frontend" / "config" / "locales.json", + ROOT + / "wordpress-plugins" + / "calorieapp-identity-bridge" + / "config" + / "locales.json", +) +EXPECTED_LOCALE_ORDER = ( + "en", + "zh-Hans", + "hi", + "es", + "ar", + "fr", + "bn", + "pt", + "id", + "ur", + "nl", +) +EXPECTED_RTL_LOCALES = {"ar", "ur"} + + +def _read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Unable to read valid JSON from {path.relative_to(ROOT)}") from exc + if not isinstance(value, dict): + raise ValueError(f"Expected an object in {path.relative_to(ROOT)}") + return value + + +def validate_locale_registry(registry: dict[str, Any]) -> None: + if registry.get("contract_id") != "gallery-token.locale-registry": + raise ValueError("Unexpected locale contract_id") + if registry.get("source_locale") != "en" or registry.get("fallback_locale") != "en": + raise ValueError("English must remain the source and fallback locale") + + locales = registry.get("locales") + if not isinstance(locales, list): + raise ValueError("locales must be a list") + tags = tuple(locale.get("tag") for locale in locales if isinstance(locale, dict)) + if tags != EXPECTED_LOCALE_ORDER: + raise ValueError( + "Locale order or membership changed; update the frozen v1 decision explicitly" + ) + if len(set(tags)) != len(tags): + raise ValueError("Locale tags must be unique") + + sources = [locale for locale in locales if locale.get("source") is True] + if len(sources) != 1 or sources[0].get("tag") != "en": + raise ValueError("English must be the sole source locale") + + rtl_tags = {locale["tag"] for locale in locales if locale.get("direction") == "rtl"} + if rtl_tags != EXPECTED_RTL_LOCALES: + raise ValueError("Arabic and Urdu must be the only v1 RTL locales") + + identifiers: dict[str, str] = {} + for locale in locales: + tag = locale["tag"] + if locale.get("direction") not in {"ltr", "rtl"}: + raise ValueError(f"Invalid direction for {tag}") + for field in ("english_name", "native_name"): + if not isinstance(locale.get(field), str) or not locale[field].strip(): + raise ValueError(f"{field} is required for {tag}") + aliases = locale.get("aliases") + if not isinstance(aliases, list): + raise ValueError(f"aliases must be a list for {tag}") + for identifier in [tag, *aliases]: + normalized = identifier.replace("_", "-").lower() + previous = identifiers.get(normalized) + if previous is not None and previous != tag: + raise ValueError(f"Locale identifier {identifier} is ambiguous") + identifiers[normalized] = tag + + +def validate_security_contract(contract: dict[str, Any]) -> None: + if contract.get("contract_id") != "calorieapp.identity-bridge.security": + raise ValueError("Unexpected security contract_id") + if contract.get("protocol_version") != "v1": + raise ValueError("IB-1 freezes protocol_version at v1") + if contract.get("login_state", {}).get("ttl_seconds") != 300: + raise ValueError("Login-state TTL must remain 300 seconds in v1") + if contract.get("authorization_code", {}).get("default_ttl_seconds") != 60: + raise ValueError("Authorization-code default TTL must remain 60 seconds in v1") + if contract.get("integrated_login_flow", {}).get("ttl_seconds") != 600: + raise ValueError("Integrated-login flow TTL must remain 600 seconds in v1") + if contract.get("application_session", {}).get("absolute_ttl_seconds") != 28800: + raise ValueError("Application-session absolute TTL must remain 28800 seconds in v1") + if contract.get("application_session", {}).get("idle_ttl_seconds") != 1800: + raise ValueError("Application-session idle TTL must remain 1800 seconds in v1") + if contract.get("identity_claims", {}).get("personal_profile_fields_enabled_by_default") != []: + raise ValueError("Personal profile fields must remain opt-in and disabled by default") + localization = contract.get("localization", {}) + if localization.get("source_locale") != "en" or localization.get("fallback_locale") != "en": + raise ValueError("Security and locale contracts disagree about English fallback") + if set(localization.get("rtl_locales", [])) != EXPECTED_RTL_LOCALES: + raise ValueError("Security and locale contracts disagree about RTL locales") + + +def synchronize(*, check: bool) -> list[Path]: + registry = _read_json(CANONICAL_LOCALES) + validate_locale_registry(registry) + validate_security_contract(_read_json(SECURITY_CONTRACT)) + source = CANONICAL_LOCALES.read_bytes() + stale: list[Path] = [] + + for target in LOCALE_TARGETS: + if target.is_file() and target.read_bytes() == source: + continue + stale.append(target) + if not check: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(source) + return stale + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Validate contracts and fail instead of updating stale runtime copies.", + ) + args = parser.parse_args() + + try: + stale = synchronize(check=args.check) + except ValueError as exc: + print(f"identity contract validation failed: {exc}", file=sys.stderr) + return 1 + + if args.check and stale: + print("Identity contract copies are stale:", file=sys.stderr) + for path in stale: + print(f"- {path.relative_to(ROOT)}", file=sys.stderr) + print("Run: python tools/sync_identity_contracts.py", file=sys.stderr) + return 1 + + action = "validated" if args.check else "synchronized" + print(f"Identity contracts {action}; {len(LOCALE_TARGETS)} runtime copies are current.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/identity_locales.test.mjs b/tools/tests/identity_locales.test.mjs new file mode 100644 index 0000000..5543d4a --- /dev/null +++ b/tools/tests/identity_locales.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const canonicalPath = path.join( + root, + "contracts", + "identity-bridge", + "v1", + "locales.json", +); +const runtimePaths = [ + path.join(root, "backend", "app", "data", "locales.json"), + path.join(root, "frontend", "config", "locales.json"), + path.join( + root, + "wordpress-plugins", + "calorieapp-identity-bridge", + "config", + "locales.json", + ), +]; +const expectedTags = [ + "en", + "zh-Hans", + "hi", + "es", + "ar", + "fr", + "bn", + "pt", + "id", + "ur", + "nl", +]; + + +test("the canonical registry freezes the eleven agreed locales", async () => { + const registry = JSON.parse(await readFile(canonicalPath, "utf8")); + assert.equal(registry.source_locale, "en"); + assert.equal(registry.fallback_locale, "en"); + assert.deepEqual( + registry.locales.map((locale) => locale.tag), + expectedTags, + ); + assert.deepEqual( + registry.locales + .filter((locale) => locale.direction === "rtl") + .map((locale) => locale.tag), + ["ar", "ur"], + ); +}); + + +test("all independently deployed artifacts use the exact registry bytes", async () => { + const canonical = await readFile(canonicalPath); + for (const runtimePath of runtimePaths) { + assert.deepEqual(await readFile(runtimePath), canonical, runtimePath); + } +}); diff --git a/tools/tests/test_build_wordpress_plugin_release.py b/tools/tests/test_build_wordpress_plugin_release.py index 7c1582f..f9e4eba 100644 --- a/tools/tests/test_build_wordpress_plugin_release.py +++ b/tools/tests/test_build_wordpress_plugin_release.py @@ -37,6 +37,7 @@ def test_build_is_reproducible_and_safe(self) -> None: self.assertFalse(any("tests/" in name for name in names)) self.assertFalse(any(name.endswith(".zip") for name in names)) self.assertIn(f"{release.PLUGIN_SLUG}/LICENSE", names) + self.assertIn(f"{release.PLUGIN_SLUG}/config/locales.json", names) def test_expected_version_must_match(self) -> None: with tempfile.TemporaryDirectory() as output: diff --git a/tools/tests/test_identity_contracts.py b/tools/tests/test_identity_contracts.py new file mode 100644 index 0000000..2639dac --- /dev/null +++ b/tools/tests/test_identity_contracts.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import json +import re +import unittest + +from tools import sync_identity_contracts as contracts + + +class IdentityContractTests(unittest.TestCase): + def test_contracts_validate_and_runtime_copies_are_current(self) -> None: + self.assertEqual(contracts.synchronize(check=True), []) + + def test_security_ttls_match_the_runtime_defaults(self) -> None: + security = json.loads(contracts.SECURITY_CONTRACT.read_text(encoding="utf-8")) + backend = (contracts.ROOT / "backend" / "app" / "main.py").read_text( + encoding="utf-8" + ) + plugin = ( + contracts.ROOT + / "wordpress-plugins" + / "calorieapp-identity-bridge" + / "includes" + / "class-calorieapp-identity-bridge-integrated-login.php" + ).read_text(encoding="utf-8") + plugin_root = ( + contracts.ROOT + / "wordpress-plugins" + / "calorieapp-identity-bridge" + / "includes" + / "class-calorieapp-identity-bridge.php" + ).read_text(encoding="utf-8") + + self.assertRegex( + backend, + re.compile( + rf'SESSION_ABSOLUTE_LIFETIME_SECONDS\s*=\s*8\s*\*\s*60\s*\*\s*60' + ), + ) + self.assertEqual(security["application_session"]["absolute_ttl_seconds"], 28800) + self.assertRegex( + backend, + re.compile(r'SESSION_IDLE_LIFETIME_SECONDS\s*=\s*30\s*\*\s*60'), + ) + self.assertEqual(security["application_session"]["idle_ttl_seconds"], 1800) + self.assertIn('os.getenv("LOGIN_STATE_LIFETIME_SECONDS", "300")', backend) + self.assertEqual(security["login_state"]["ttl_seconds"], 300) + self.assertIn("FLOW_TTL_SECONDS = 10 * MINUTE_IN_SECONDS", plugin) + self.assertEqual(security["integrated_login_flow"]["ttl_seconds"], 600) + self.assertIn("'code_ttl_seconds' => 60", plugin_root) + self.assertEqual(security["authorization_code"]["default_ttl_seconds"], 60) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/wordpress_locale_registry.test.php b/tools/tests/wordpress_locale_registry.test.php new file mode 100644 index 0000000..80f3294 --- /dev/null +++ b/tools/tests/wordpress_locale_registry.test.php @@ -0,0 +1,29 @@ + (string) $locale['tag'], + self::all()['locales'] + ) + ); + } + + public static function resolve(?string $requested): string { + $registry = self::all(); + $identifiers = []; + $canonical_primary_tags = []; + + foreach ($registry['locales'] as $locale) { + $tag = (string) $locale['tag']; + $identifiers[self::normalize_identifier($tag)] = $tag; + foreach ((array) ($locale['aliases'] ?? []) as $alias) { + $identifiers[self::normalize_identifier((string) $alias)] = $tag; + } + if (strpos($tag, '-') === false) { + $canonical_primary_tags[strtolower($tag)] = $tag; + } + } + + foreach (explode(',', (string) $requested) as $part) { + $candidate = trim(explode(';', $part, 2)[0]); + if ($candidate === '' || $candidate === '*') { + continue; + } + $normalized = self::normalize_identifier($candidate); + if (isset($identifiers[$normalized])) { + return $identifiers[$normalized]; + } + $primary = explode('-', $normalized, 2)[0]; + if (isset($canonical_primary_tags[$primary])) { + return $canonical_primary_tags[$primary]; + } + } + + return (string) ($registry['fallback_locale'] ?? 'en'); + } + + public static function direction(?string $requested): string { + $resolved = self::resolve($requested); + foreach (self::all()['locales'] as $locale) { + if ((string) $locale['tag'] === $resolved) { + return (string) $locale['direction']; + } + } + return 'ltr'; + } + + private static function normalize_identifier(string $value): string { + return strtolower(str_replace('_', '-', trim($value))); + } + + private static function fallback_registry(): array { + return [ + 'source_locale' => 'en', + 'fallback_locale' => 'en', + 'locales' => [ + [ + 'tag' => 'en', + 'english_name' => 'English', + 'native_name' => 'English', + 'direction' => 'ltr', + 'source' => true, + 'aliases' => [], + ], + ], + ]; + } +} diff --git a/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge.php b/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge.php index b7003c5..2b6fb9c 100644 --- a/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge.php +++ b/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge.php @@ -7,6 +7,7 @@ } require_once __DIR__ . '/class-calorieapp-identity-bridge-storage.php'; +require_once __DIR__ . '/class-calorieapp-identity-bridge-locale-registry.php'; require_once __DIR__ . '/class-calorieapp-identity-bridge-rest.php'; require_once __DIR__ . '/class-calorieapp-identity-bridge-browser-authorize.php'; require_once __DIR__ . '/class-calorieapp-identity-bridge-integrated-login.php'; From 0607aad15c41146c25b53205bf359eaadaababaf Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sun, 30 Aug 2026 06:05:58 +0200 Subject: [PATCH 2/4] Preserve historical assets in localized variants --- .github/workflows/ci.yml | 1 + README.md | 1 + contracts/localization/v1/README.md | 21 ++++++++ .../localization/v1/asset-localization.json | 50 +++++++++++++++++++ .../v1/asset-variants.schema.json | 47 +++++++++++++++++ tools/tests/test_localization_contracts.py | 37 ++++++++++++++ 6 files changed, 157 insertions(+) create mode 100644 contracts/localization/v1/README.md create mode 100644 contracts/localization/v1/asset-localization.json create mode 100644 contracts/localization/v1/asset-variants.schema.json create mode 100644 tools/tests/test_localization_contracts.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e74c1d..193f9e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,7 @@ jobs: run: | python tools/sync_identity_contracts.py --check python -m unittest tools.tests.test_identity_contracts + python -m unittest tools.tests.test_localization_contracts wordpress-plugin-release-check: name: WordPress plugin release check diff --git a/README.md b/README.md index b983e95..47d1713 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ gate can additionally run the local developer health check. ## Documentation - Versioned Identity Bridge contracts: contracts/identity-bridge/v1/ +- Historical image localization contract: contracts/localization/v1/ - Public architecture: docs/public/architecture.md - Public roadmap: docs/public/roadmap.md - Public deployment guide: docs/public/deployment.md diff --git a/contracts/localization/v1/README.md b/contracts/localization/v1/README.md new file mode 100644 index 0000000..3e4f333 --- /dev/null +++ b/contracts/localization/v1/README.md @@ -0,0 +1,21 @@ +# Gallery Token localized image contract v1 + +Imported website images that contain English text require localized derived +variants for the same eleven locales as the website and Identity Bridge. + +The historical source image is immutable. A translated variant must preserve +the same composition, historical identity, colors, typography character, +shapes, background, illustration or photograph, decoration and visual +hierarchy. Only the words, locale-specific characters and strictly necessary +text-fit or writing-direction adjustments may differ. Factual details may be +updated only when that content change is separately reviewed. + +Use the layered source whenever it exists. If only a raster upload is +available, retain that upload unchanged and work on a copy. Each source and +variant must be recorded in a manifest conforming to +`asset-variants.schema.json`, including the source checksum and the precise +approved differences. + +No localized image is published directly. It follows translation review, +historical-design comparison, RTL/text-fit checks, preview, review and an +explicit GO. diff --git a/contracts/localization/v1/asset-localization.json b/contracts/localization/v1/asset-localization.json new file mode 100644 index 0000000..f2e0411 --- /dev/null +++ b/contracts/localization/v1/asset-localization.json @@ -0,0 +1,50 @@ +{ + "contract_id": "gallery-token.asset-localization", + "contract_version": "1.0.0", + "original_asset_policy": { + "retain_unchanged_original": true, + "original_is_historical_source": true, + "localized_files_are_derived_variants": true + }, + "visual_invariants": [ + "historical_brand_identity", + "composition", + "color_palette", + "typography_character", + "button_and_shape_language", + "backgrounds", + "illustrations_and_photography", + "decorative_elements", + "visual_hierarchy" + ], + "allowed_variant_changes": [ + "translated_text", + "locale_appropriate_characters", + "line_breaks", + "text_fit", + "minor_spacing_or_layout_adjustments_required_by_text_length_or_writing_direction", + "explicitly_reviewed factual detail updates" + ], + "forbidden_changes": [ + "generic_visual_redesign", + "replacement_of_historical_style", + "unapproved_color_or_font_substitution", + "lossy_overwrite_of_the_original", + "untracked_text_or_fact_changes" + ], + "source_policy": { + "prefer_layered_source_files": true, + "raster_edit_allowed_when_layered_source_is_unavailable": true, + "source_and_variant_must_be_traceable": true, + "source_checksum_required_in_variant_manifest": true + }, + "quality_gate": [ + "text_extraction", + "translation_review", + "historical_design_comparison", + "rtl_and_text_fit_check", + "preview", + "review", + "explicit_go" + ] +} diff --git a/contracts/localization/v1/asset-variants.schema.json b/contracts/localization/v1/asset-variants.schema.json new file mode 100644 index 0000000..bc7e6c2 --- /dev/null +++ b/contracts/localization/v1/asset-variants.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://calorietoken.net/contracts/localization/v1/asset-variants.schema.json", + "title": "Gallery Token localized image variant manifest", + "type": "object", + "additionalProperties": false, + "required": ["manifest_version", "source_asset", "variants"], + "properties": { + "manifest_version": { "const": "1.0.0" }, + "source_asset": { + "type": "object", + "additionalProperties": false, + "required": ["id", "path", "sha256", "historical_original_retained"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "path": { "type": "string", "minLength": 1 }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "layered_source_path": { "type": ["string", "null"] }, + "historical_original_retained": { "const": true } + } + }, + "variants": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "locale", + "path", + "translated_text", + "approved_adjustments", + "status" + ], + "properties": { + "locale": { "type": "string", "minLength": 2 }, + "path": { "type": "string", "minLength": 1 }, + "translated_text": { "type": "array", "items": { "type": "string" } }, + "approved_adjustments": { "type": "array", "items": { "type": "string" } }, + "review_notes": { "type": "string" }, + "status": { + "enum": ["draft", "translation-reviewed", "design-reviewed", "approved"] + } + } + } + } + } +} diff --git a/tools/tests/test_localization_contracts.py b/tools/tests/test_localization_contracts.py new file mode 100644 index 0000000..69757cf --- /dev/null +++ b/tools/tests/test_localization_contracts.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +POLICY_PATH = ROOT / "contracts" / "localization" / "v1" / "asset-localization.json" +LOCALES_PATH = ROOT / "contracts" / "identity-bridge" / "v1" / "locales.json" + + +class LocalizationContractTests(unittest.TestCase): + def test_historical_original_and_identity_are_immutable(self) -> None: + policy = json.loads(POLICY_PATH.read_text(encoding="utf-8")) + self.assertTrue(policy["original_asset_policy"]["retain_unchanged_original"]) + self.assertTrue(policy["original_asset_policy"]["original_is_historical_source"]) + self.assertIn("historical_brand_identity", policy["visual_invariants"]) + self.assertIn("composition", policy["visual_invariants"]) + self.assertIn("color_palette", policy["visual_invariants"]) + self.assertIn("typography_character", policy["visual_invariants"]) + self.assertIn("translated_text", policy["allowed_variant_changes"]) + self.assertIn("generic_visual_redesign", policy["forbidden_changes"]) + + def test_image_workflow_uses_the_shared_locale_source(self) -> None: + registry = json.loads(LOCALES_PATH.read_text(encoding="utf-8")) + self.assertEqual(len(registry["locales"]), 11) + self.assertEqual(registry["source_locale"], "en") + self.assertEqual(registry["fallback_locale"], "en") + + def test_localized_images_require_the_existing_publication_gate(self) -> None: + policy = json.loads(POLICY_PATH.read_text(encoding="utf-8")) + self.assertEqual(policy["quality_gate"][-3:], ["preview", "review", "explicit_go"]) + + +if __name__ == "__main__": + unittest.main() From a282f52181b25da49c7f34d9e347233a91e3abf4 Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:02:22 +0200 Subject: [PATCH 3/4] Correct CalorieApp and CalorieToken contract names --- backend/app/data/locales.json | 2 +- contracts/identity-bridge/v1/README.md | 2 +- contracts/identity-bridge/v1/locales.json | 2 +- contracts/identity-bridge/v1/locales.schema.json | 4 ++-- contracts/localization/v1/README.md | 2 +- contracts/localization/v1/asset-localization.json | 2 +- contracts/localization/v1/asset-variants.schema.json | 2 +- frontend/config/locales.json | 2 +- tools/sync_identity_contracts.py | 2 +- tools/tests/test_localization_contracts.py | 2 ++ wordpress-plugins/calorieapp-identity-bridge/README.md | 2 +- .../calorieapp-identity-bridge/config/locales.json | 2 +- 12 files changed, 14 insertions(+), 12 deletions(-) diff --git a/backend/app/data/locales.json b/backend/app/data/locales.json index a3bf88e..eac2835 100644 --- a/backend/app/data/locales.json +++ b/backend/app/data/locales.json @@ -1,6 +1,6 @@ { "$schema": "./locales.schema.json", - "contract_id": "gallery-token.locale-registry", + "contract_id": "calorieapp.locale-registry", "contract_version": "1.0.0", "source_locale": "en", "fallback_locale": "en", diff --git a/contracts/identity-bridge/v1/README.md b/contracts/identity-bridge/v1/README.md index 0e73696..1fb8329 100644 --- a/contracts/identity-bridge/v1/README.md +++ b/contracts/identity-bridge/v1/README.md @@ -1,7 +1,7 @@ # CalorieApp Identity Bridge contract v1 This directory is the repository source of truth for the first Identity Bridge -contract and the shared Gallery Token locale registry. Runtime copies are +contract and the shared CalorieApp locale registry. Runtime copies are generated for the backend, frontend and WordPress plugin because those three artifacts are deployed and packaged independently. diff --git a/contracts/identity-bridge/v1/locales.json b/contracts/identity-bridge/v1/locales.json index a3bf88e..eac2835 100644 --- a/contracts/identity-bridge/v1/locales.json +++ b/contracts/identity-bridge/v1/locales.json @@ -1,6 +1,6 @@ { "$schema": "./locales.schema.json", - "contract_id": "gallery-token.locale-registry", + "contract_id": "calorieapp.locale-registry", "contract_version": "1.0.0", "source_locale": "en", "fallback_locale": "en", diff --git a/contracts/identity-bridge/v1/locales.schema.json b/contracts/identity-bridge/v1/locales.schema.json index ba6ece9..da17047 100644 --- a/contracts/identity-bridge/v1/locales.schema.json +++ b/contracts/identity-bridge/v1/locales.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://calorietoken.net/contracts/identity-bridge/v1/locales.schema.json", - "title": "Gallery Token locale registry", + "title": "CalorieApp locale registry", "type": "object", "additionalProperties": false, "required": [ @@ -14,7 +14,7 @@ ], "properties": { "$schema": { "type": "string" }, - "contract_id": { "const": "gallery-token.locale-registry" }, + "contract_id": { "const": "calorieapp.locale-registry" }, "contract_version": { "type": "string", "pattern": "^[1-9][0-9]*\\.[0-9]+\\.[0-9]+$" }, "source_locale": { "type": "string" }, "fallback_locale": { "type": "string" }, diff --git a/contracts/localization/v1/README.md b/contracts/localization/v1/README.md index 3e4f333..dbce48d 100644 --- a/contracts/localization/v1/README.md +++ b/contracts/localization/v1/README.md @@ -1,4 +1,4 @@ -# Gallery Token localized image contract v1 +# CalorieToken localized image contract v1 Imported website images that contain English text require localized derived variants for the same eleven locales as the website and Identity Bridge. diff --git a/contracts/localization/v1/asset-localization.json b/contracts/localization/v1/asset-localization.json index f2e0411..b34ca5a 100644 --- a/contracts/localization/v1/asset-localization.json +++ b/contracts/localization/v1/asset-localization.json @@ -1,5 +1,5 @@ { - "contract_id": "gallery-token.asset-localization", + "contract_id": "calorietoken.asset-localization", "contract_version": "1.0.0", "original_asset_policy": { "retain_unchanged_original": true, diff --git a/contracts/localization/v1/asset-variants.schema.json b/contracts/localization/v1/asset-variants.schema.json index bc7e6c2..8998299 100644 --- a/contracts/localization/v1/asset-variants.schema.json +++ b/contracts/localization/v1/asset-variants.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://calorietoken.net/contracts/localization/v1/asset-variants.schema.json", - "title": "Gallery Token localized image variant manifest", + "title": "CalorieToken localized image variant manifest", "type": "object", "additionalProperties": false, "required": ["manifest_version", "source_asset", "variants"], diff --git a/frontend/config/locales.json b/frontend/config/locales.json index a3bf88e..eac2835 100644 --- a/frontend/config/locales.json +++ b/frontend/config/locales.json @@ -1,6 +1,6 @@ { "$schema": "./locales.schema.json", - "contract_id": "gallery-token.locale-registry", + "contract_id": "calorieapp.locale-registry", "contract_version": "1.0.0", "source_locale": "en", "fallback_locale": "en", diff --git a/tools/sync_identity_contracts.py b/tools/sync_identity_contracts.py index 39535c5..04eeacc 100644 --- a/tools/sync_identity_contracts.py +++ b/tools/sync_identity_contracts.py @@ -50,7 +50,7 @@ def _read_json(path: Path) -> dict[str, Any]: def validate_locale_registry(registry: dict[str, Any]) -> None: - if registry.get("contract_id") != "gallery-token.locale-registry": + if registry.get("contract_id") != "calorieapp.locale-registry": raise ValueError("Unexpected locale contract_id") if registry.get("source_locale") != "en" or registry.get("fallback_locale") != "en": raise ValueError("English must remain the source and fallback locale") diff --git a/tools/tests/test_localization_contracts.py b/tools/tests/test_localization_contracts.py index 69757cf..2a41c39 100644 --- a/tools/tests/test_localization_contracts.py +++ b/tools/tests/test_localization_contracts.py @@ -13,6 +13,7 @@ class LocalizationContractTests(unittest.TestCase): def test_historical_original_and_identity_are_immutable(self) -> None: policy = json.loads(POLICY_PATH.read_text(encoding="utf-8")) + self.assertEqual(policy["contract_id"], "calorietoken.asset-localization") self.assertTrue(policy["original_asset_policy"]["retain_unchanged_original"]) self.assertTrue(policy["original_asset_policy"]["original_is_historical_source"]) self.assertIn("historical_brand_identity", policy["visual_invariants"]) @@ -24,6 +25,7 @@ def test_historical_original_and_identity_are_immutable(self) -> None: def test_image_workflow_uses_the_shared_locale_source(self) -> None: registry = json.loads(LOCALES_PATH.read_text(encoding="utf-8")) + self.assertEqual(registry["contract_id"], "calorieapp.locale-registry") self.assertEqual(len(registry["locales"]), 11) self.assertEqual(registry["source_locale"], "en") self.assertEqual(registry["fallback_locale"], "en") diff --git a/wordpress-plugins/calorieapp-identity-bridge/README.md b/wordpress-plugins/calorieapp-identity-bridge/README.md index 0f61ee0..2952766 100644 --- a/wordpress-plugins/calorieapp-identity-bridge/README.md +++ b/wordpress-plugins/calorieapp-identity-bridge/README.md @@ -37,7 +37,7 @@ The browser endpoint is intentionally not REST. XUMM Login establishes a normal Details are in SECURITY.md and CONFIGURATION.md. Version 0.3.0 adds the deployable copy and pure resolver for the shared, -versioned eleven-locale Gallery Token registry. It does not yet alter public +versioned eleven-locale CalorieApp registry. It does not yet alter public copy or automatically publish translations; unsupported input fails safely to English and Arabic and Urdu are marked right-to-left. Version 0.2.4 only checks a Xaman signature after the user actually opens diff --git a/wordpress-plugins/calorieapp-identity-bridge/config/locales.json b/wordpress-plugins/calorieapp-identity-bridge/config/locales.json index a3bf88e..eac2835 100644 --- a/wordpress-plugins/calorieapp-identity-bridge/config/locales.json +++ b/wordpress-plugins/calorieapp-identity-bridge/config/locales.json @@ -1,6 +1,6 @@ { "$schema": "./locales.schema.json", - "contract_id": "gallery-token.locale-registry", + "contract_id": "calorieapp.locale-registry", "contract_version": "1.0.0", "source_locale": "en", "fallback_locale": "en", From cdf171c467af66a0554175ba812bee4e6efc120a Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:25:15 +0200 Subject: [PATCH 4/4] Resolve PR #27 locale registry review findings --- .../tests/wordpress_locale_registry.test.php | 38 ++++++++++++++++ .../calorieapp-identity-bridge/README.md | 4 +- ...rieapp-identity-bridge-locale-registry.php | 45 ++++++++++++++++++- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/tools/tests/wordpress_locale_registry.test.php b/tools/tests/wordpress_locale_registry.test.php index 80f3294..a1fde97 100644 --- a/tools/tests/wordpress_locale_registry.test.php +++ b/tools/tests/wordpress_locale_registry.test.php @@ -27,3 +27,41 @@ if (LocaleRegistry::direction('nl') !== 'ltr') { throw new RuntimeException('WordPress LTR resolution failed.'); } + +$config_path = dirname(__DIR__, 2) + . '/wordpress-plugins/calorieapp-identity-bridge/config/locales.json'; +$original_config = file_get_contents($config_path); +if (!is_string($original_config)) { + throw new RuntimeException('Unable to read the WordPress locale registry fixture.'); +} + +$registry_cache = new ReflectionProperty(LocaleRegistry::class, 'registry'); +$registry_cache->setAccessible(true); + +try { + $corrupted_config = json_encode([ + 'source_locale' => 'en', + 'fallback_locale' => 'en', + 'locales' => [null, ['tag' => 'ar', 'direction' => 'rtl', 'aliases' => []]], + ]); + if (!is_string($corrupted_config) || file_put_contents($config_path, $corrupted_config) === false) { + throw new RuntimeException('Unable to create the corrupted locale registry fixture.'); + } + + $registry_cache->setValue(null, null); + if (LocaleRegistry::tags() !== ['en']) { + throw new RuntimeException('A malformed WordPress locale registry must fail safely.'); + } + if (LocaleRegistry::resolve('ar') !== 'en' || LocaleRegistry::direction('ar') !== 'ltr') { + throw new RuntimeException('Malformed registry fallback must remain safe English LTR.'); + } +} finally { + if (file_put_contents($config_path, $original_config) === false) { + throw new RuntimeException('Unable to restore the WordPress locale registry fixture.'); + } + $registry_cache->setValue(null, null); +} + +if (LocaleRegistry::tags() !== $expected) { + throw new RuntimeException('WordPress locale registry did not recover after fallback testing.'); +} diff --git a/wordpress-plugins/calorieapp-identity-bridge/README.md b/wordpress-plugins/calorieapp-identity-bridge/README.md index 2952766..34e753b 100644 --- a/wordpress-plugins/calorieapp-identity-bridge/README.md +++ b/wordpress-plugins/calorieapp-identity-bridge/README.md @@ -38,8 +38,8 @@ Details are in SECURITY.md and CONFIGURATION.md. Version 0.3.0 adds the deployable copy and pure resolver for the shared, versioned eleven-locale CalorieApp registry. It does not yet alter public -copy or automatically publish translations; unsupported input fails safely to -English and Arabic and Urdu are marked right-to-left. +copy or automatically publish translations. Unsupported input falls back +safely to English. Arabic and Urdu are the right-to-left locales. Version 0.2.4 only checks a Xaman signature after the user actually opens Xaman, keeps retrying pending signature checks after the user returns, and shows CalorieApp startup retry progress without disguising backend errors as a diff --git a/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-locale-registry.php b/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-locale-registry.php index 2b25ede..1f3cfd0 100644 --- a/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-locale-registry.php +++ b/wordpress-plugins/calorieapp-identity-bridge/includes/class-calorieapp-identity-bridge-locale-registry.php @@ -18,7 +18,7 @@ public static function all(): array { $contents = is_readable($path) ? file_get_contents($path) : false; $decoded = is_string($contents) ? json_decode($contents, true) : null; - if (!is_array($decoded) || !isset($decoded['locales']) || !is_array($decoded['locales'])) { + if (!self::is_valid_registry($decoded)) { self::$registry = self::fallback_registry(); return self::$registry; } @@ -84,6 +84,49 @@ private static function normalize_identifier(string $value): string { return strtolower(str_replace('_', '-', trim($value))); } + private static function is_valid_registry($registry): bool { + if (!is_array($registry) || !isset($registry['locales']) || !is_array($registry['locales'])) { + return false; + } + + $tags = []; + foreach ($registry['locales'] as $locale) { + if (!is_array($locale)) { + return false; + } + + $tag = $locale['tag'] ?? null; + $direction = $locale['direction'] ?? null; + $aliases = $locale['aliases'] ?? null; + if ( + !is_string($tag) + || trim($tag) === '' + || !in_array($direction, ['ltr', 'rtl'], true) + || !is_array($aliases) + ) { + return false; + } + + foreach ($aliases as $alias) { + if (!is_string($alias) || trim($alias) === '') { + return false; + } + } + + if (in_array($tag, $tags, true)) { + return false; + } + $tags[] = $tag; + } + + $source = $registry['source_locale'] ?? null; + $fallback = $registry['fallback_locale'] ?? null; + return is_string($source) + && is_string($fallback) + && in_array($source, $tags, true) + && in_array($fallback, $tags, true); + } + private static function fallback_registry(): array { return [ 'source_locale' => 'en',