From 67ddee28b252de71731c27233a439f41e478ca5d Mon Sep 17 00:00:00 2001 From: Yvv Date: Wed, 21 Oct 2026 02:04:42 +0200 Subject: [PATCH] =?UTF-8?q?v3=20:=20socle=20Rust=20(moteur=20pur,=20acc?= =?UTF-8?q?=C3=A8s=20trustinWallet,=20serveur)=20;=20maquette=20Nuxt=20ran?= =?UTF-8?q?g=C3=A9e=20dans=20maquette/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 10 + Cargo.lock | 2742 +++++++++++++++++ Cargo.toml | 22 + crates/ld-engine/Cargo.toml | 17 + crates/ld-engine/src/assist.rs | 232 ++ crates/ld-engine/src/compose.rs | 402 +++ crates/ld-engine/src/edit.rs | 243 ++ crates/ld-engine/src/lib.rs | 14 + crates/ld-engine/src/threshold.rs | 285 ++ crates/ld-server/Cargo.toml | 37 + crates/ld-server/migrations/0001_init.sql | 181 ++ crates/ld-server/src/access.rs | 243 ++ crates/ld-server/src/adoption.rs | 185 ++ crates/ld-server/src/auth.rs | 516 ++++ crates/ld-server/src/config.rs | 139 + crates/ld-server/src/docs.rs | 618 ++++ crates/ld-server/src/error.rs | 50 + crates/ld-server/src/lib.rs | 182 ++ crates/ld-server/src/main.rs | 6 + crates/ld-server/src/proposals.rs | 797 +++++ crates/ld-server/src/publish.rs | 251 ++ crates/ld-server/src/seed.rs | 204 ++ crates/ld-server/src/util.rs | 63 + crates/ld-server/src/web.rs | 30 + crates/ld-server/src/wot.rs | 128 + crates/ld-server/tests/api.rs | 388 +++ crates/trustin-access/Cargo.toml | 13 + crates/trustin-access/src/lib.rs | 193 ++ .../recherche/forum-duniter-2026-02.md | 0 {backend => maquette/backend}/alembic.ini | 0 {backend => maquette/backend}/alembic/env.py | 0 .../backend}/alembic/script.py.mako | 0 ..._04_23_0000-0b9c1d2e3f4a_initial_schema.py | 0 ..._23_1227-70914b334cfb_add_organizations.py | 0 ...b78571ae9e00_add_qualification_protocol.py | 0 ...2026_04_23_1900-c4e812fb3a01_add_groups.py | 0 ...24_1000-d91a3c7f8b02_add_mandate_origin.py | 0 ..._25_1000-e3f4a5b6c7d8_mandate_origin_id.py | 0 {backend => maquette/backend}/app/__init__.py | 0 {backend => maquette/backend}/app/config.py | 0 {backend => maquette/backend}/app/database.py | 0 .../backend}/app/dependencies/__init__.py | 0 .../backend}/app/dependencies/org.py | 0 .../backend}/app/engine/__init__.py | 0 .../backend}/app/engine/mode_params.py | 0 .../backend}/app/engine/nuanced_vote.py | 0 .../backend}/app/engine/qualifier.py | 0 .../backend}/app/engine/smith_threshold.py | 0 .../backend}/app/engine/techcomm_threshold.py | 0 .../backend}/app/engine/threshold.py | 0 {backend => maquette/backend}/app/main.py | 0 .../backend}/app/middleware/__init__.py | 0 .../backend}/app/middleware/rate_limiter.py | 0 .../app/middleware/security_headers.py | 0 .../backend}/app/models/__init__.py | 0 .../backend}/app/models/cache.py | 0 .../backend}/app/models/decision.py | 0 .../backend}/app/models/document.py | 0 .../backend}/app/models/group.py | 0 .../backend}/app/models/mandate.py | 0 .../backend}/app/models/organization.py | 0 .../backend}/app/models/protocol.py | 0 .../backend}/app/models/qualification.py | 0 .../backend}/app/models/sanctuary.py | 0 .../backend}/app/models/user.py | 0 .../backend}/app/models/vote.py | 0 .../backend}/app/routers/__init__.py | 0 .../backend}/app/routers/auth.py | 0 .../backend}/app/routers/decisions.py | 0 .../backend}/app/routers/documents.py | 0 .../backend}/app/routers/groups.py | 0 .../backend}/app/routers/mandates.py | 0 .../backend}/app/routers/organizations.py | 0 .../backend}/app/routers/protocols.py | 0 .../backend}/app/routers/public.py | 0 .../backend}/app/routers/qualify.py | 0 .../backend}/app/routers/sanctuary.py | 0 .../backend}/app/routers/votes.py | 0 .../backend}/app/routers/websocket.py | 0 .../backend}/app/schemas/__init__.py | 0 .../backend}/app/schemas/auth.py | 0 .../backend}/app/schemas/decision.py | 0 .../backend}/app/schemas/document.py | 0 .../backend}/app/schemas/group.py | 0 .../backend}/app/schemas/mandate.py | 0 .../backend}/app/schemas/organization.py | 0 .../backend}/app/schemas/protocol.py | 0 .../backend}/app/schemas/qualification.py | 0 .../backend}/app/schemas/sanctuary.py | 0 .../backend}/app/schemas/vote.py | 0 .../backend}/app/services/__init__.py | 0 .../backend}/app/services/auth_service.py | 0 .../app/services/blockchain_service.py | 0 .../backend}/app/services/cache_service.py | 0 .../backend}/app/services/decision_service.py | 0 .../backend}/app/services/document_service.py | 0 .../backend}/app/services/ipfs_service.py | 0 .../backend}/app/services/mandate_service.py | 0 .../backend}/app/services/org_service.py | 0 .../app/services/qualify_ai_service.py | 0 .../app/services/sanctuary_service.py | 0 .../backend}/app/services/vote_service.py | 0 .../backend}/app/tests/__init__.py | 0 .../backend}/app/tests/test_cache_service.py | 0 .../backend}/app/tests/test_decisions.py | 0 .../tests/test_doc_protocol_integration.py | 0 .../backend}/app/tests/test_documents.py | 0 .../backend}/app/tests/test_mandate_flows.py | 0 .../backend}/app/tests/test_mandates.py | 0 .../backend}/app/tests/test_middleware.py | 0 .../backend}/app/tests/test_mode_params.py | 0 .../backend}/app/tests/test_nuanced.py | 0 .../backend}/app/tests/test_public_api.py | 0 .../backend}/app/tests/test_qualifier.py | 0 .../backend}/app/tests/test_qualifier_ai.py | 0 .../backend}/app/tests/test_threshold.py | 0 .../backend}/app/tests/test_votes.py | 0 {backend => maquette/backend}/conftest.py | 0 {backend => maquette/backend}/pytest.ini | 0 .../backend}/requirements.txt | 0 .../backend}/scripts/export_seed_bundle.py | 0 {backend => maquette/backend}/seed.py | 0 .../docker}/backend.Dockerfile | 0 .../docker}/docker-compose.dev.yml | 0 .../docker}/docker-compose.yml | 0 .../docker}/frontend.Dockerfile | 0 .../docs-v1}/dev/1.index.md | 0 .../docs-v1}/dev/10.spike-workflow-engine.md | 0 .../docs-v1}/dev/11.governance-modalities.md | 0 .../docs-v1}/dev/2.architecture.md | 0 .../docs-v1}/dev/3.api-reference.md | 0 .../docs-v1}/dev/4.database-schema.md | 0 .../docs-v1}/dev/5.formulas.md | 0 .../docs-v1}/dev/6.blockchain-integration.md | 0 .../docs-v1}/dev/7.contributing.md | 0 .../docs-v1}/dev/8.deployment.md | 0 .../docs-v1}/dev/9.security.md | 0 .../docs-v1}/user/1.index.md | 0 .../docs-v1}/user/2.getting-started.md | 0 .../docs-v1}/user/3.documents.md | 0 .../docs-v1}/user/4.decisions.md | 0 .../docs-v1}/user/5.voting.md | 0 .../docs-v1}/user/6.mandates.md | 0 .../docs-v1}/user/7.sanctuary.md | 0 .../docs-v1}/user/8.faq.md | 0 .../frontend}/app/app.config.ts | 0 {frontend => maquette/frontend}/app/app.vue | 0 .../frontend}/app/assets/css/moods.css | 0 .../frontend}/app/assets/css/overlays.css | 0 .../components/chemin/CheminAlternatives.vue | 0 .../app/components/chemin/CheminChips.vue | 0 .../components/chemin/CheminParamEditor.vue | 0 .../app/components/chemin/CheminPathCard.vue | 0 .../app/components/chemin/CheminQ0.vue | 0 .../app/components/chemin/CheminScope.vue | 0 .../components/chemin/CheminSuggestion.vue | 0 .../app/components/common/LdAvatarStack.vue | 0 .../app/components/common/LdCountdown.vue | 0 .../app/components/common/LdMoodSwitcher.vue | 0 .../app/components/common/LdSeal.vue | 0 .../components/common/LdWorkspaceSelector.vue | 0 .../components/common/MarkdownRenderer.vue | 0 .../components/decisions/DecisionChain.vue | 0 .../components/decisions/DecisionElements.vue | 0 .../components/decisions/DecisionInstruct.vue | 0 .../decisions/DecisionPerimeter.vue | 0 .../components/decisions/DecisionProof.vue | 0 .../decisions/DecisionRegistryCard.vue | 0 .../decisions/DecisionRegistryFilters.vue | 0 .../components/decisions/DecisionSession.vue | 0 .../components/decisions/DecisionTimeline.vue | 0 .../components/decisions/DecisionVigor.vue | 0 .../components/decisions/DecisionWindow.vue | 0 .../app/components/decisions/decisionUi.ts | 0 .../app/components/feed/FeedActivityCard.vue | 0 .../app/components/feed/FeedAdviceCard.vue | 0 .../app/components/feed/FeedCapture.vue | 0 .../app/components/feed/FeedItemCard.vue | 0 .../app/components/feed/FeedReviewCard.vue | 0 .../app/components/feed/FeedSection.vue | 0 .../app/components/feed/FeedVoteCard.vue | 0 .../app/components/feed/FeedWindowCard.vue | 0 .../app/components/mandates/MandateCard.vue | 0 .../components/mandates/MandateExercise.vue | 0 .../mandates/MandateNominationPicker.vue | 0 .../components/mandates/MandatePeriodBar.vue | 0 .../components/mandates/MandateSpotlights.vue | 0 .../components/mandates/ObservatoryBars.vue | 0 .../mandates/ObservatorySection.vue | 0 .../components/mandates/ObservatoryStat.vue | 0 .../app/components/mandates/mandateUi.ts | 0 .../onboarding/DataCollectivesCard.vue | 0 .../onboarding/DataIdentityCard.vue | 0 .../components/onboarding/DataImportCard.vue | 0 .../onboarding/OnboardingIdentity.vue | 0 .../onboarding/OnboardingMembers.vue | 0 .../onboarding/OnboardingTemplates.vue | 0 .../components/texts/AtelierFormulaLab.vue | 0 .../components/texts/AtelierMedianDemo.vue | 0 .../app/components/texts/ClauseCard.vue | 0 .../app/components/texts/ClauseDetail.vue | 0 .../components/texts/ClauseVersionDiff.vue | 0 .../app/components/texts/InertiaBadge.vue | 0 .../app/components/texts/SessionMiniGauge.vue | 0 .../app/components/texts/TextDocCard.vue | 0 .../app/components/texts/TextProvenance.vue | 0 .../app/components/texts/textsModel.ts | 0 .../app/components/votes/VoteBinary.vue | 0 .../app/components/votes/VoteConsent.vue | 0 .../app/components/votes/VoteElection.vue | 0 .../app/components/votes/VoteInstruct.vue | 0 .../app/components/votes/VoteMyHistory.vue | 0 .../app/components/votes/VoteNuanced.vue | 0 .../app/components/votes/VoteParamSlider.vue | 0 .../app/components/votes/VoteParametric.vue | 0 .../frontend}/app/composables/useFeed.ts | 0 .../frontend}/app/composables/useMood.ts | 0 .../frontend}/app/composables/useSearch.ts | 0 .../frontend}/app/data/persistence.ts | 0 .../data/seeds/atelier-du-canal.bundle.json | 0 .../app/data/seeds/duniter-g1.bundle.json | 0 .../frontend}/app/data/templates.ts | 0 .../frontend}/app/engine/impact.ts | 0 .../frontend}/app/engine/index.ts | 0 .../frontend}/app/engine/modeParams.ts | 0 .../frontend}/app/engine/nuanced.ts | 0 .../frontend}/app/engine/parametric.ts | 0 .../frontend}/app/engine/settings.ts | 0 .../frontend}/app/engine/state.ts | 0 .../frontend}/app/engine/threshold.ts | 0 .../frontend}/app/engine/triage.ts | 0 .../frontend}/app/layouts/bare.vue | 0 .../frontend}/app/layouts/default.vue | 0 .../frontend}/app/lexicon.ts | 0 .../frontend}/app/pages/creer.vue | 0 .../frontend}/app/pages/decider.vue | 0 .../app/pages/decisions/[id]/index.vue | 0 .../app/pages/decisions/[id]/vote.vue | 0 .../frontend}/app/pages/decisions/index.vue | 0 .../app/pages/decisions/observatoire.vue | 0 .../frontend}/app/pages/donnees.vue | 0 .../frontend}/app/pages/index.vue | 0 .../frontend}/app/pages/mandats/[id].vue | 0 .../frontend}/app/pages/mandats/index.vue | 0 .../frontend}/app/pages/mandats/nouveau.vue | 0 .../frontend}/app/pages/textes/[slug].vue | 0 .../frontend}/app/pages/textes/formules.vue | 0 .../frontend}/app/pages/textes/index.vue | 0 .../frontend}/app/stores/collective.ts | 0 .../frontend}/app/stores/decisions.ts | 0 .../frontend}/app/types/domain.ts | 0 .../frontend}/nuxt.config.ts | 0 .../frontend}/package-lock.json | 0 {frontend => maquette/frontend}/package.json | 0 .../frontend}/public/hexagram-tsing-flat.svg | 0 .../frontend}/public/hexagram-tsing.svg | 0 .../frontend}/tests/engine/election.spec.ts | 0 .../frontend}/tests/engine/impact.spec.ts | 0 .../frontend}/tests/engine/modeParams.spec.ts | 0 .../frontend}/tests/engine/nuanced.spec.ts | 0 .../frontend}/tests/engine/parametric.spec.ts | 0 .../frontend}/tests/engine/settings.spec.ts | 0 .../frontend}/tests/engine/state.spec.ts | 0 .../frontend}/tests/engine/threshold.spec.ts | 0 .../frontend}/tests/engine/triage.spec.ts | 0 .../frontend}/tests/lexicon.spec.ts | 0 .../tests/seeds/atelier-du-canal.spec.ts | 0 .../frontend}/tests/seeds/duniter-g1.spec.ts | 0 .../frontend}/tests/stores/decisions.spec.ts | 0 {frontend => maquette/frontend}/tsconfig.json | 0 {frontend => maquette/frontend}/uno.config.ts | 0 .../frontend}/vitest.config.ts | 0 rust-toolchain.toml | 3 + rustfmt.toml | 2 + seeds/acte-engagement-certification.json | 783 +++++ 275 files changed, 8979 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/ld-engine/Cargo.toml create mode 100644 crates/ld-engine/src/assist.rs create mode 100644 crates/ld-engine/src/compose.rs create mode 100644 crates/ld-engine/src/edit.rs create mode 100644 crates/ld-engine/src/lib.rs create mode 100644 crates/ld-engine/src/threshold.rs create mode 100644 crates/ld-server/Cargo.toml create mode 100644 crates/ld-server/migrations/0001_init.sql create mode 100644 crates/ld-server/src/access.rs create mode 100644 crates/ld-server/src/adoption.rs create mode 100644 crates/ld-server/src/auth.rs create mode 100644 crates/ld-server/src/config.rs create mode 100644 crates/ld-server/src/docs.rs create mode 100644 crates/ld-server/src/error.rs create mode 100644 crates/ld-server/src/lib.rs create mode 100644 crates/ld-server/src/main.rs create mode 100644 crates/ld-server/src/proposals.rs create mode 100644 crates/ld-server/src/publish.rs create mode 100644 crates/ld-server/src/seed.rs create mode 100644 crates/ld-server/src/util.rs create mode 100644 crates/ld-server/src/web.rs create mode 100644 crates/ld-server/src/wot.rs create mode 100644 crates/ld-server/tests/api.rs create mode 100644 crates/trustin-access/Cargo.toml create mode 100644 crates/trustin-access/src/lib.rs rename research_duniter_forum.md => docs/recherche/forum-duniter-2026-02.md (100%) rename {backend => maquette/backend}/alembic.ini (100%) rename {backend => maquette/backend}/alembic/env.py (100%) rename {backend => maquette/backend}/alembic/script.py.mako (100%) rename {backend => maquette/backend}/alembic/versions/2026_04_23_0000-0b9c1d2e3f4a_initial_schema.py (100%) rename {backend => maquette/backend}/alembic/versions/2026_04_23_1227-70914b334cfb_add_organizations.py (100%) rename {backend => maquette/backend}/alembic/versions/2026_04_23_1708-b78571ae9e00_add_qualification_protocol.py (100%) rename {backend => maquette/backend}/alembic/versions/2026_04_23_1900-c4e812fb3a01_add_groups.py (100%) rename {backend => maquette/backend}/alembic/versions/2026_04_24_1000-d91a3c7f8b02_add_mandate_origin.py (100%) rename {backend => maquette/backend}/alembic/versions/2026_04_25_1000-e3f4a5b6c7d8_mandate_origin_id.py (100%) rename {backend => maquette/backend}/app/__init__.py (100%) rename {backend => maquette/backend}/app/config.py (100%) rename {backend => maquette/backend}/app/database.py (100%) rename {backend => maquette/backend}/app/dependencies/__init__.py (100%) rename {backend => maquette/backend}/app/dependencies/org.py (100%) rename {backend => maquette/backend}/app/engine/__init__.py (100%) rename {backend => maquette/backend}/app/engine/mode_params.py (100%) rename {backend => maquette/backend}/app/engine/nuanced_vote.py (100%) rename {backend => maquette/backend}/app/engine/qualifier.py (100%) rename {backend => maquette/backend}/app/engine/smith_threshold.py (100%) rename {backend => maquette/backend}/app/engine/techcomm_threshold.py (100%) rename {backend => maquette/backend}/app/engine/threshold.py (100%) rename {backend => maquette/backend}/app/main.py (100%) rename {backend => maquette/backend}/app/middleware/__init__.py (100%) rename {backend => maquette/backend}/app/middleware/rate_limiter.py (100%) rename {backend => maquette/backend}/app/middleware/security_headers.py (100%) rename {backend => maquette/backend}/app/models/__init__.py (100%) rename {backend => maquette/backend}/app/models/cache.py (100%) rename {backend => maquette/backend}/app/models/decision.py (100%) rename {backend => maquette/backend}/app/models/document.py (100%) rename {backend => maquette/backend}/app/models/group.py (100%) rename {backend => maquette/backend}/app/models/mandate.py (100%) rename {backend => maquette/backend}/app/models/organization.py (100%) rename {backend => maquette/backend}/app/models/protocol.py (100%) rename {backend => maquette/backend}/app/models/qualification.py (100%) rename {backend => maquette/backend}/app/models/sanctuary.py (100%) rename {backend => maquette/backend}/app/models/user.py (100%) rename {backend => maquette/backend}/app/models/vote.py (100%) rename {backend => maquette/backend}/app/routers/__init__.py (100%) rename {backend => maquette/backend}/app/routers/auth.py (100%) rename {backend => maquette/backend}/app/routers/decisions.py (100%) rename {backend => maquette/backend}/app/routers/documents.py (100%) rename {backend => maquette/backend}/app/routers/groups.py (100%) rename {backend => maquette/backend}/app/routers/mandates.py (100%) rename {backend => maquette/backend}/app/routers/organizations.py (100%) rename {backend => maquette/backend}/app/routers/protocols.py (100%) rename {backend => maquette/backend}/app/routers/public.py (100%) rename {backend => maquette/backend}/app/routers/qualify.py (100%) rename {backend => maquette/backend}/app/routers/sanctuary.py (100%) rename {backend => maquette/backend}/app/routers/votes.py (100%) rename {backend => maquette/backend}/app/routers/websocket.py (100%) rename {backend => maquette/backend}/app/schemas/__init__.py (100%) rename {backend => maquette/backend}/app/schemas/auth.py (100%) rename {backend => maquette/backend}/app/schemas/decision.py (100%) rename {backend => maquette/backend}/app/schemas/document.py (100%) rename {backend => maquette/backend}/app/schemas/group.py (100%) rename {backend => maquette/backend}/app/schemas/mandate.py (100%) rename {backend => maquette/backend}/app/schemas/organization.py (100%) rename {backend => maquette/backend}/app/schemas/protocol.py (100%) rename {backend => maquette/backend}/app/schemas/qualification.py (100%) rename {backend => maquette/backend}/app/schemas/sanctuary.py (100%) rename {backend => maquette/backend}/app/schemas/vote.py (100%) rename {backend => maquette/backend}/app/services/__init__.py (100%) rename {backend => maquette/backend}/app/services/auth_service.py (100%) rename {backend => maquette/backend}/app/services/blockchain_service.py (100%) rename {backend => maquette/backend}/app/services/cache_service.py (100%) rename {backend => maquette/backend}/app/services/decision_service.py (100%) rename {backend => maquette/backend}/app/services/document_service.py (100%) rename {backend => maquette/backend}/app/services/ipfs_service.py (100%) rename {backend => maquette/backend}/app/services/mandate_service.py (100%) rename {backend => maquette/backend}/app/services/org_service.py (100%) rename {backend => maquette/backend}/app/services/qualify_ai_service.py (100%) rename {backend => maquette/backend}/app/services/sanctuary_service.py (100%) rename {backend => maquette/backend}/app/services/vote_service.py (100%) rename {backend => maquette/backend}/app/tests/__init__.py (100%) rename {backend => maquette/backend}/app/tests/test_cache_service.py (100%) rename {backend => maquette/backend}/app/tests/test_decisions.py (100%) rename {backend => maquette/backend}/app/tests/test_doc_protocol_integration.py (100%) rename {backend => maquette/backend}/app/tests/test_documents.py (100%) rename {backend => maquette/backend}/app/tests/test_mandate_flows.py (100%) rename {backend => maquette/backend}/app/tests/test_mandates.py (100%) rename {backend => maquette/backend}/app/tests/test_middleware.py (100%) rename {backend => maquette/backend}/app/tests/test_mode_params.py (100%) rename {backend => maquette/backend}/app/tests/test_nuanced.py (100%) rename {backend => maquette/backend}/app/tests/test_public_api.py (100%) rename {backend => maquette/backend}/app/tests/test_qualifier.py (100%) rename {backend => maquette/backend}/app/tests/test_qualifier_ai.py (100%) rename {backend => maquette/backend}/app/tests/test_threshold.py (100%) rename {backend => maquette/backend}/app/tests/test_votes.py (100%) rename {backend => maquette/backend}/conftest.py (100%) rename {backend => maquette/backend}/pytest.ini (100%) rename {backend => maquette/backend}/requirements.txt (100%) rename {backend => maquette/backend}/scripts/export_seed_bundle.py (100%) rename {backend => maquette/backend}/seed.py (100%) rename {docker => maquette/docker}/backend.Dockerfile (100%) rename {docker => maquette/docker}/docker-compose.dev.yml (100%) rename {docker => maquette/docker}/docker-compose.yml (100%) rename {docker => maquette/docker}/frontend.Dockerfile (100%) rename {docs/content => maquette/docs-v1}/dev/1.index.md (100%) rename {docs/content => maquette/docs-v1}/dev/10.spike-workflow-engine.md (100%) rename {docs/content => maquette/docs-v1}/dev/11.governance-modalities.md (100%) rename {docs/content => maquette/docs-v1}/dev/2.architecture.md (100%) rename {docs/content => maquette/docs-v1}/dev/3.api-reference.md (100%) rename {docs/content => maquette/docs-v1}/dev/4.database-schema.md (100%) rename {docs/content => maquette/docs-v1}/dev/5.formulas.md (100%) rename {docs/content => maquette/docs-v1}/dev/6.blockchain-integration.md (100%) rename {docs/content => maquette/docs-v1}/dev/7.contributing.md (100%) rename {docs/content => maquette/docs-v1}/dev/8.deployment.md (100%) rename {docs/content => maquette/docs-v1}/dev/9.security.md (100%) rename {docs/content => maquette/docs-v1}/user/1.index.md (100%) rename {docs/content => maquette/docs-v1}/user/2.getting-started.md (100%) rename {docs/content => maquette/docs-v1}/user/3.documents.md (100%) rename {docs/content => maquette/docs-v1}/user/4.decisions.md (100%) rename {docs/content => maquette/docs-v1}/user/5.voting.md (100%) rename {docs/content => maquette/docs-v1}/user/6.mandates.md (100%) rename {docs/content => maquette/docs-v1}/user/7.sanctuary.md (100%) rename {docs/content => maquette/docs-v1}/user/8.faq.md (100%) rename {frontend => maquette/frontend}/app/app.config.ts (100%) rename {frontend => maquette/frontend}/app/app.vue (100%) rename {frontend => maquette/frontend}/app/assets/css/moods.css (100%) rename {frontend => maquette/frontend}/app/assets/css/overlays.css (100%) rename {frontend => maquette/frontend}/app/components/chemin/CheminAlternatives.vue (100%) rename {frontend => maquette/frontend}/app/components/chemin/CheminChips.vue (100%) rename {frontend => maquette/frontend}/app/components/chemin/CheminParamEditor.vue (100%) rename {frontend => maquette/frontend}/app/components/chemin/CheminPathCard.vue (100%) rename {frontend => maquette/frontend}/app/components/chemin/CheminQ0.vue (100%) rename {frontend => maquette/frontend}/app/components/chemin/CheminScope.vue (100%) rename {frontend => maquette/frontend}/app/components/chemin/CheminSuggestion.vue (100%) rename {frontend => maquette/frontend}/app/components/common/LdAvatarStack.vue (100%) rename {frontend => maquette/frontend}/app/components/common/LdCountdown.vue (100%) rename {frontend => maquette/frontend}/app/components/common/LdMoodSwitcher.vue (100%) rename {frontend => maquette/frontend}/app/components/common/LdSeal.vue (100%) rename {frontend => maquette/frontend}/app/components/common/LdWorkspaceSelector.vue (100%) rename {frontend => maquette/frontend}/app/components/common/MarkdownRenderer.vue (100%) rename {frontend => maquette/frontend}/app/components/decisions/DecisionChain.vue (100%) rename {frontend => maquette/frontend}/app/components/decisions/DecisionElements.vue (100%) rename {frontend => maquette/frontend}/app/components/decisions/DecisionInstruct.vue (100%) rename {frontend => maquette/frontend}/app/components/decisions/DecisionPerimeter.vue (100%) rename {frontend => maquette/frontend}/app/components/decisions/DecisionProof.vue (100%) rename {frontend => maquette/frontend}/app/components/decisions/DecisionRegistryCard.vue (100%) rename {frontend => maquette/frontend}/app/components/decisions/DecisionRegistryFilters.vue (100%) rename {frontend => maquette/frontend}/app/components/decisions/DecisionSession.vue (100%) rename {frontend => maquette/frontend}/app/components/decisions/DecisionTimeline.vue (100%) rename {frontend => maquette/frontend}/app/components/decisions/DecisionVigor.vue (100%) rename {frontend => maquette/frontend}/app/components/decisions/DecisionWindow.vue (100%) rename {frontend => maquette/frontend}/app/components/decisions/decisionUi.ts (100%) rename {frontend => maquette/frontend}/app/components/feed/FeedActivityCard.vue (100%) rename {frontend => maquette/frontend}/app/components/feed/FeedAdviceCard.vue (100%) rename {frontend => maquette/frontend}/app/components/feed/FeedCapture.vue (100%) rename {frontend => maquette/frontend}/app/components/feed/FeedItemCard.vue (100%) rename {frontend => maquette/frontend}/app/components/feed/FeedReviewCard.vue (100%) rename {frontend => maquette/frontend}/app/components/feed/FeedSection.vue (100%) rename {frontend => maquette/frontend}/app/components/feed/FeedVoteCard.vue (100%) rename {frontend => maquette/frontend}/app/components/feed/FeedWindowCard.vue (100%) rename {frontend => maquette/frontend}/app/components/mandates/MandateCard.vue (100%) rename {frontend => maquette/frontend}/app/components/mandates/MandateExercise.vue (100%) rename {frontend => maquette/frontend}/app/components/mandates/MandateNominationPicker.vue (100%) rename {frontend => maquette/frontend}/app/components/mandates/MandatePeriodBar.vue (100%) rename {frontend => maquette/frontend}/app/components/mandates/MandateSpotlights.vue (100%) rename {frontend => maquette/frontend}/app/components/mandates/ObservatoryBars.vue (100%) rename {frontend => maquette/frontend}/app/components/mandates/ObservatorySection.vue (100%) rename {frontend => maquette/frontend}/app/components/mandates/ObservatoryStat.vue (100%) rename {frontend => maquette/frontend}/app/components/mandates/mandateUi.ts (100%) rename {frontend => maquette/frontend}/app/components/onboarding/DataCollectivesCard.vue (100%) rename {frontend => maquette/frontend}/app/components/onboarding/DataIdentityCard.vue (100%) rename {frontend => maquette/frontend}/app/components/onboarding/DataImportCard.vue (100%) rename {frontend => maquette/frontend}/app/components/onboarding/OnboardingIdentity.vue (100%) rename {frontend => maquette/frontend}/app/components/onboarding/OnboardingMembers.vue (100%) rename {frontend => maquette/frontend}/app/components/onboarding/OnboardingTemplates.vue (100%) rename {frontend => maquette/frontend}/app/components/texts/AtelierFormulaLab.vue (100%) rename {frontend => maquette/frontend}/app/components/texts/AtelierMedianDemo.vue (100%) rename {frontend => maquette/frontend}/app/components/texts/ClauseCard.vue (100%) rename {frontend => maquette/frontend}/app/components/texts/ClauseDetail.vue (100%) rename {frontend => maquette/frontend}/app/components/texts/ClauseVersionDiff.vue (100%) rename {frontend => maquette/frontend}/app/components/texts/InertiaBadge.vue (100%) rename {frontend => maquette/frontend}/app/components/texts/SessionMiniGauge.vue (100%) rename {frontend => maquette/frontend}/app/components/texts/TextDocCard.vue (100%) rename {frontend => maquette/frontend}/app/components/texts/TextProvenance.vue (100%) rename {frontend => maquette/frontend}/app/components/texts/textsModel.ts (100%) rename {frontend => maquette/frontend}/app/components/votes/VoteBinary.vue (100%) rename {frontend => maquette/frontend}/app/components/votes/VoteConsent.vue (100%) rename {frontend => maquette/frontend}/app/components/votes/VoteElection.vue (100%) rename {frontend => maquette/frontend}/app/components/votes/VoteInstruct.vue (100%) rename {frontend => maquette/frontend}/app/components/votes/VoteMyHistory.vue (100%) rename {frontend => maquette/frontend}/app/components/votes/VoteNuanced.vue (100%) rename {frontend => maquette/frontend}/app/components/votes/VoteParamSlider.vue (100%) rename {frontend => maquette/frontend}/app/components/votes/VoteParametric.vue (100%) rename {frontend => maquette/frontend}/app/composables/useFeed.ts (100%) rename {frontend => maquette/frontend}/app/composables/useMood.ts (100%) rename {frontend => maquette/frontend}/app/composables/useSearch.ts (100%) rename {frontend => maquette/frontend}/app/data/persistence.ts (100%) rename {frontend => maquette/frontend}/app/data/seeds/atelier-du-canal.bundle.json (100%) rename {frontend => maquette/frontend}/app/data/seeds/duniter-g1.bundle.json (100%) rename {frontend => maquette/frontend}/app/data/templates.ts (100%) rename {frontend => maquette/frontend}/app/engine/impact.ts (100%) rename {frontend => maquette/frontend}/app/engine/index.ts (100%) rename {frontend => maquette/frontend}/app/engine/modeParams.ts (100%) rename {frontend => maquette/frontend}/app/engine/nuanced.ts (100%) rename {frontend => maquette/frontend}/app/engine/parametric.ts (100%) rename {frontend => maquette/frontend}/app/engine/settings.ts (100%) rename {frontend => maquette/frontend}/app/engine/state.ts (100%) rename {frontend => maquette/frontend}/app/engine/threshold.ts (100%) rename {frontend => maquette/frontend}/app/engine/triage.ts (100%) rename {frontend => maquette/frontend}/app/layouts/bare.vue (100%) rename {frontend => maquette/frontend}/app/layouts/default.vue (100%) rename {frontend => maquette/frontend}/app/lexicon.ts (100%) rename {frontend => maquette/frontend}/app/pages/creer.vue (100%) rename {frontend => maquette/frontend}/app/pages/decider.vue (100%) rename {frontend => maquette/frontend}/app/pages/decisions/[id]/index.vue (100%) rename {frontend => maquette/frontend}/app/pages/decisions/[id]/vote.vue (100%) rename {frontend => maquette/frontend}/app/pages/decisions/index.vue (100%) rename {frontend => maquette/frontend}/app/pages/decisions/observatoire.vue (100%) rename {frontend => maquette/frontend}/app/pages/donnees.vue (100%) rename {frontend => maquette/frontend}/app/pages/index.vue (100%) rename {frontend => maquette/frontend}/app/pages/mandats/[id].vue (100%) rename {frontend => maquette/frontend}/app/pages/mandats/index.vue (100%) rename {frontend => maquette/frontend}/app/pages/mandats/nouveau.vue (100%) rename {frontend => maquette/frontend}/app/pages/textes/[slug].vue (100%) rename {frontend => maquette/frontend}/app/pages/textes/formules.vue (100%) rename {frontend => maquette/frontend}/app/pages/textes/index.vue (100%) rename {frontend => maquette/frontend}/app/stores/collective.ts (100%) rename {frontend => maquette/frontend}/app/stores/decisions.ts (100%) rename {frontend => maquette/frontend}/app/types/domain.ts (100%) rename {frontend => maquette/frontend}/nuxt.config.ts (100%) rename {frontend => maquette/frontend}/package-lock.json (100%) rename {frontend => maquette/frontend}/package.json (100%) rename {frontend => maquette/frontend}/public/hexagram-tsing-flat.svg (100%) rename {frontend => maquette/frontend}/public/hexagram-tsing.svg (100%) rename {frontend => maquette/frontend}/tests/engine/election.spec.ts (100%) rename {frontend => maquette/frontend}/tests/engine/impact.spec.ts (100%) rename {frontend => maquette/frontend}/tests/engine/modeParams.spec.ts (100%) rename {frontend => maquette/frontend}/tests/engine/nuanced.spec.ts (100%) rename {frontend => maquette/frontend}/tests/engine/parametric.spec.ts (100%) rename {frontend => maquette/frontend}/tests/engine/settings.spec.ts (100%) rename {frontend => maquette/frontend}/tests/engine/state.spec.ts (100%) rename {frontend => maquette/frontend}/tests/engine/threshold.spec.ts (100%) rename {frontend => maquette/frontend}/tests/engine/triage.spec.ts (100%) rename {frontend => maquette/frontend}/tests/lexicon.spec.ts (100%) rename {frontend => maquette/frontend}/tests/seeds/atelier-du-canal.spec.ts (100%) rename {frontend => maquette/frontend}/tests/seeds/duniter-g1.spec.ts (100%) rename {frontend => maquette/frontend}/tests/stores/decisions.spec.ts (100%) rename {frontend => maquette/frontend}/tsconfig.json (100%) rename {frontend => maquette/frontend}/uno.config.ts (100%) rename {frontend => maquette/frontend}/vitest.config.ts (100%) create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100644 seeds/acte-engagement-certification.json diff --git a/.gitignore b/.gitignore index 0a7cb1f..2ce7da6 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,13 @@ htmlcov/ # IPFS ipfs-data/ docs/*.pdf + +# v3 — Rust + Svelte +/target/ +/data/ +web/build/ +web/.svelte-kit/ +web/test-results/ +web/playwright-report/ +*.db-wal +*.db-shm diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..198bc13 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2742 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-compression" +version = "0.4.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "compression-codecs" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "ld-engine" +version = "0.3.0" +dependencies = [ + "serde", + "serde_json", + "unicode-normalization", +] + +[[package]] +name = "ld-server" +version = "0.3.0" +dependencies = [ + "anyhow", + "axum", + "base64 0.22.1", + "hex", + "http-body-util", + "ld-engine", + "rand 0.9.5", + "reqwest", + "serde", + "serde_json", + "sha2", + "sqlx", + "thiserror", + "time", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "trustin-access", + "twox-hash", + "url", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "rsa", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.8", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "trustin-access" +version = "0.3.0" +dependencies = [ + "serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twox-hash" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9322a19 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[workspace] +resolver = "3" +members = ["crates/ld-engine", "crates/trustin-access", "crates/ld-server"] + +[workspace.package] +version = "0.3.0" +edition = "2024" +rust-version = "1.90" +repository = "https://git.open.us.org/LPK/decision" +publish = false + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } +unwrap_used = "warn" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true diff --git a/crates/ld-engine/Cargo.toml b/crates/ld-engine/Cargo.toml new file mode 100644 index 0000000..487b5fe --- /dev/null +++ b/crates/ld-engine/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ld-engine" +description = "Pure decision engines of libreDecision: inertial threshold, edit classification, editing assistant, document composition." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +unicode-normalization = "0.1" + +[dev-dependencies] +serde_json = "1" + +[lints] +workspace = true diff --git a/crates/ld-engine/src/assist.rs b/crates/ld-engine/src/assist.rs new file mode 100644 index 0000000..40e5230 --- /dev/null +++ b/crates/ld-engine/src/assist.rs @@ -0,0 +1,232 @@ +//! Rule-based editing assistant — no AI yet (Yvv, 2026-10-21: « D'abord des +//! règles simples, ensuite on mettra de l'IA, mais locale »). +//! +//! It never decides. It says, while the author writes: +//! - « this text seems to carry several decisions — split it? » with the parts; +//! - « this text is close to — make it a variant instead? »; +//! - « this text is long ». +//! +//! The local AI planned later plugs in behind the same `Hint` type. + +use serde::{Deserialize, Serialize}; +use unicode_normalization::UnicodeNormalization; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Hint { + /// Several decisions in one text: the suggested parts, in order. + Split { parts: Vec }, + /// Close to an existing unit or proposal. + Similar { target: String, score: f64 }, + /// Longer than a unit usually is. + Long { chars: usize }, +} + +pub const LONG_UNIT_CHARS: usize = 700; +pub const SIMILARITY_HINT: f64 = 0.45; + +/// Markers of a commitment or a rule, folded (lowercase, no accents). +const DECISION_MARKERS: &[&str] = &[ + "je m'engage", + "je m engage", + "j'ai ", + "je suis ", + "je me suis", + "je verifie", + "je ne ", + "doit ", + "doivent ", + "s'engage", + "est tenu", + "est interdit", + "obligatoire", + "il est interdit", + "verifiez", + "ne certifiez", + "contactez", + "assurez", + "i commit", + "i have ", + "must ", + "shall ", + "me comprometo", + "debe ", +]; + +const STOPWORDS: &[&str] = &[ + "le", "la", "les", "un", "une", "des", "de", "du", "d", "l", "et", "ou", "a", "au", "aux", "en", "que", "qui", + "je", "j", "me", "m", "se", "s", "ce", "cet", "cette", "ces", "par", "pour", "sur", "dans", "avec", "est", "sont", + "the", "of", "and", "to", "in", "is", "el", "los", "las", "y", "que", "con", "por", "para", +]; + +fn fold(s: &str) -> String { + s.nfd() + .filter(|c| !matches!(*c as u32, 0x0300..=0x036F)) + .flat_map(char::to_lowercase) + .map(|c| if c == '’' { '\'' } else { c }) + .collect() +} + +fn is_decision(segment: &str) -> bool { + let f = fold(segment); + DECISION_MARKERS.iter().any(|m| f.contains(m)) +} + +/// Paragraphs, list items: the natural cut lines of a modular text. +fn segments(text: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut current = String::new(); + let flush = |current: &mut String, out: &mut Vec| { + let t = current.trim(); + if !t.is_empty() { + out.push(t.to_owned()); + } + current.clear(); + }; + for line in text.lines() { + let trimmed = line.trim_start(); + let is_item = trimmed.starts_with("- ") + || trimmed.starts_with("* ") + || trimmed + .split_once(['.', ')']) + .is_some_and(|(n, _)| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); + if trimmed.is_empty() || is_item { + flush(&mut current, &mut out); + } + if !trimmed.is_empty() { + if !current.is_empty() { + current.push('\n'); + } + current.push_str(line); + } + } + flush(&mut current, &mut out); + out +} + +/// Sentences of a single paragraph (split after . ! ? followed by a space). +fn sentences(paragraph: &str) -> Vec { + let mut out = Vec::new(); + let mut current = String::new(); + let mut chars = paragraph.chars().peekable(); + while let Some(c) = chars.next() { + current.push(c); + if matches!(c, '.' | '!' | '?') && chars.peek().is_some_and(|n| n.is_whitespace()) { + out.push(current.trim().to_owned()); + current.clear(); + } + } + if !current.trim().is_empty() { + out.push(current.trim().to_owned()); + } + out +} + +/// The parts to propose separately, or `None` when the text holds one decision. +pub fn split_suggestion(text: &str) -> Option> { + let segs = segments(text); + if segs.len() >= 2 && segs.iter().filter(|s| is_decision(s)).count() >= 2 { + return Some(segs); + } + if segs.len() == 1 { + let sents = sentences(&segs[0]); + let committing = sents.iter().filter(|s| is_decision(s)).count(); + if sents.len() >= 2 && committing >= 2 { + return Some(sents); + } + } + None +} + +fn content_words(text: &str) -> Vec { + fold(text) + .split(|c: char| !c.is_alphanumeric()) + .filter(|w| w.chars().count() > 2 && !STOPWORDS.contains(w)) + .map(str::to_owned) + .collect() +} + +/// Jaccard similarity over content words and their bigrams. +pub fn similarity(a: &str, b: &str) -> f64 { + use std::collections::BTreeSet; + let grams = |t: &str| -> BTreeSet { + let w = content_words(t); + let mut set: BTreeSet = w.iter().cloned().collect(); + set.extend(w.windows(2).map(|p| format!("{} {}", p[0], p[1]))); + set + }; + let (x, y) = (grams(a), grams(b)); + if x.is_empty() || y.is_empty() { + return 0.0; + } + let inter = x.intersection(&y).count() as f64; + let union = x.union(&y).count() as f64; + inter / union +} + +/// All hints for a draft, given the texts it may collide with (`(id, text)`). +pub fn hints(text: &str, others: &[(String, String)]) -> Vec { + let mut out = Vec::new(); + if let Some(parts) = split_suggestion(text) { + out.push(Hint::Split { parts }); + } + let mut close: Vec<(String, f64)> = + others.iter().map(|(id, t)| (id.clone(), similarity(text, t))).filter(|(_, s)| *s >= SIMILARITY_HINT).collect(); + close.sort_by(|a, b| b.1.total_cmp(&a.1)); + out.extend(close.into_iter().take(3).map(|(target, score)| Hint::Similar { target, score })); + let chars = text.chars().count(); + if chars > LONG_UNIT_CHARS { + out.push(Hint::Long { chars }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_commitments_in_one_text_are_flagged() { + let text = "Je m'engage sur l'honneur à n'avoir qu'un seul compte.\n\nJe m'engage à ne certifier que des personnes qui respectent cet engagement."; + let parts = split_suggestion(text).expect("two decisions"); + assert_eq!(parts.len(), 2); + assert!(parts[0].starts_with("Je m'engage sur")); + } + + #[test] + fn two_commitments_in_one_paragraph() { + let text = "Je m'engage à vérifier la clé. Je m'engage aussi à rester joignable."; + assert_eq!(split_suggestion(text).map(|p| p.len()), Some(2)); + } + + #[test] + fn a_list_of_rules_is_flagged_but_one_explained_commitment_is_not() { + let list = "- Vérifiez la clé avec la personne.\n- Contactez les certifieurs existants."; + assert_eq!(split_suggestion(list).map(|p| p.len()), Some(2)); + let single = "Je m'engage sur l'honneur à n'avoir qu'un seul compte. C'est le socle de la toile."; + assert_eq!(split_suggestion(single), None); + } + + #[test] + fn preamble_like_text_is_not_split() { + let text = "Le présent acte définit les obligations réciproques des membres. Cet acte est l'unique relation contractuelle de notre toile."; + assert_eq!(split_suggestion(text), None); + } + + #[test] + fn similarity_finds_a_variant() { + let current = "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de monnaie G1."; + let variant = "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1."; + let other = "J'ai vérifié avec la personne certifiée qu'elle a généré son document de révocation."; + assert!(similarity(current, variant) >= SIMILARITY_HINT); + assert!(similarity(current, other) < 0.2); + let h = hints(variant, &[("E1".into(), current.into()), ("E6".into(), other.into())]); + assert!(matches!(&h[..], [Hint::Similar { target, .. }] if target == "E1")); + } + + #[test] + fn long_text() { + let text = "mot ".repeat(200); + assert!(hints(&text, &[]).iter().any(|h| matches!(h, Hint::Long { .. }))); + } +} diff --git a/crates/ld-engine/src/compose.rs b/crates/ld-engine/src/compose.rs new file mode 100644 index 0000000..a97c89b --- /dev/null +++ b/crates/ld-engine/src/compose.rs @@ -0,0 +1,402 @@ +//! Composition of a modular document under permanent vote. +//! +//! A document is an ordered list of **units** (preamble, articles, headings). +//! A unit is a slot: it may hold one version in force, or none yet. Every +//! **proposal** targets exactly one unit — one proposal, one decision — and is +//! one of: a text for the unit (new or replacing), its removal, or a change of +//! its inertia. Proposals on the same unit and the same dimension (content or +//! inertia) are rivals: at most one of them enters into force at a time. +//! +//! A rival replaces the version in force only if it reached its threshold +//! **and** holds a wider margin than the version in force (for − required). +//! The displaced version goes back among the proposals with its votes; it can +//! only return by widening its margin again — no oscillation. A seed a minima +//! (in force without any vote) has the margin of zero votes, so the first +//! rival that reaches its threshold replaces it. +//! +//! This module answers three questions, purely: +//! - which proposals enter into force now (`adoptions`); +//! - what the document would read if the well-rated, or all, proposals passed +//! (`preview`); +//! - which formula a proposal is judged by (`params_for`). + +use crate::threshold::{FormulaParams, InertiaPreset, Tally, ThresholdError, evaluate}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct VersionText { + pub id: String, + pub title: String, + pub body: String, + /// Votes still cast on the version in force (permanent vote). + #[serde(default)] + pub tally: Tally, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct UnitState { + pub id: String, + pub position: f64, + pub inertia: InertiaPreset, + pub in_force: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "inertia", rename_all = "lowercase")] +pub enum ProposalKind { + /// A text for the unit: an addition if the unit is empty, a replacement otherwise. + Text, + /// Remove the unit from the text in force. + Remove, + /// Change the inertia of the unit. + Inertia(InertiaPreset), +} + +impl ProposalKind { + fn is_content(self) -> bool { + !matches!(self, Self::Inertia(_)) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ProposalState { + pub id: String, + pub unit_id: String, + pub kind: ProposalKind, + pub title: String, + pub body: String, + pub tally: Tally, + /// Sortable submission stamp (RFC 3339): the earlier wins a perfect tie. + pub submitted_at: String, + /// An active contradiction signal forbids entering into force. + pub blocked: bool, +} + +/// A change of inertia is voted one level above the unit (« inertie + »). +pub fn params_for(unit: &UnitState, kind: ProposalKind, floor: f64) -> FormulaParams { + let preset = match kind { + ProposalKind::Inertia(_) => unit.inertia.raised(), + ProposalKind::Text | ProposalKind::Remove => unit.inertia, + }; + FormulaParams::for_inertia(preset, floor) +} + +/// A proposal that changes nothing cannot enter into force. +fn is_effective(unit: &UnitState, p: &ProposalState) -> bool { + match p.kind { + ProposalKind::Text => unit.in_force.as_ref().is_none_or(|v| v.title != p.title || v.body != p.body), + ProposalKind::Remove => unit.in_force.is_some(), + ProposalKind::Inertia(target) => target != unit.inertia, + } +} + +#[derive(Clone, Debug)] +struct Scored<'a> { + proposal: &'a ProposalState, + margin: i64, + reached: bool, + well_rated: bool, +} + +fn score<'a>( + unit: &UnitState, + p: &'a ProposalState, + electorate: u32, + floor: f64, +) -> Result, ThresholdError> { + let e = evaluate(p.tally, electorate, ¶ms_for(unit, p.kind, floor))?; + Ok(Scored { + proposal: p, + margin: i64::from(p.tally.for_votes) - i64::from(e.required), + reached: e.reached, + well_rated: e.well_rated, + }) +} + +/// Best of rivals: widest margin, then most *for* votes, then earliest. +fn best<'a>(mut rivals: Vec>) -> Option<&'a ProposalState> { + rivals.sort_by(|a, b| { + b.margin + .cmp(&a.margin) + .then(b.proposal.tally.for_votes.cmp(&a.proposal.tally.for_votes)) + .then(a.proposal.submitted_at.cmp(&b.proposal.submitted_at)) + .then(a.proposal.id.cmp(&b.proposal.id)) + }); + rivals.first().map(|s| s.proposal) +} + +/// Margin of the version in force, judged like a text proposal on its unit. +fn in_force_margin(unit: &UnitState, electorate: u32, floor: f64) -> Result, ThresholdError> { + let Some(v) = &unit.in_force else { return Ok(None) }; + let e = evaluate(v.tally, electorate, ¶ms_for(unit, ProposalKind::Text, floor))?; + Ok(Some(i64::from(v.tally.for_votes) - i64::from(e.required))) +} + +/// The proposals that enter into force now: per unit and dimension, the best +/// of those that reached their threshold, are effective and not blocked — and, +/// for content, hold a wider margin than the version in force. +pub fn adoptions( + units: &[UnitState], + proposals: &[ProposalState], + electorate: u32, + floor: f64, +) -> Result, ThresholdError> { + let mut out = Vec::new(); + for unit in units { + let incumbent = in_force_margin(unit, electorate, floor)?; + for content in [true, false] { + let mut rivals = Vec::new(); + for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content() == content) { + if p.blocked || !is_effective(unit, p) { + continue; + } + let s = score(unit, p, electorate, floor)?; + let beats_incumbent = !content || incumbent.is_none_or(|m| s.margin > m); + if s.reached && beats_incumbent { + rivals.push(s); + } + } + if let Some(winner) = best(rivals) { + out.push(winner.id.clone()); + } + } + } + Ok(out) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PreviewMode { + /// Only the in-force text. + InForce, + /// As if every well-rated proposal passed (« si les bien cotées passent »). + WellRated, + /// As if every proposal passed, whatever its rating (« si tout passe »). + All, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PreviewMark { + Unchanged, + Added, + Replaced, + Removed, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PreviewUnit { + pub unit_id: String, + pub title: String, + pub body: String, + pub mark: PreviewMark, + /// The proposal that would bring the change. + pub proposal_id: Option, +} + +/// The document as it would read. Removed units are kept with their mark so +/// the screen can show them struck through. +pub fn preview( + units: &[UnitState], + proposals: &[ProposalState], + electorate: u32, + floor: f64, + mode: PreviewMode, +) -> Result, ThresholdError> { + let mut ordered: Vec<&UnitState> = units.iter().collect(); + ordered.sort_by(|a, b| a.position.total_cmp(&b.position).then(a.id.cmp(&b.id))); + let mut out = Vec::new(); + for unit in ordered { + let chosen = if mode == PreviewMode::InForce { + None + } else { + let mut rivals = Vec::new(); + for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content()) { + if !is_effective(unit, p) { + continue; + } + let s = score(unit, p, electorate, floor)?; + if mode == PreviewMode::All || s.well_rated { + rivals.push(s); + } + } + if mode == PreviewMode::All { + // Whatever its rating: the most supported, then the earliest. + for r in &mut rivals { + r.margin = 0; + } + } + best(rivals) + }; + match (chosen, &unit.in_force) { + (Some(p), current) => { + let mark = match (p.kind, current) { + (ProposalKind::Remove, _) => PreviewMark::Removed, + (_, Some(_)) => PreviewMark::Replaced, + (_, None) => PreviewMark::Added, + }; + let (title, body) = match (p.kind, current) { + (ProposalKind::Remove, Some(v)) => (v.title.clone(), v.body.clone()), + _ => (p.title.clone(), p.body.clone()), + }; + out.push(PreviewUnit { unit_id: unit.id.clone(), title, body, mark, proposal_id: Some(p.id.clone()) }); + } + (None, Some(v)) => out.push(PreviewUnit { + unit_id: unit.id.clone(), + title: v.title.clone(), + body: v.body.clone(), + mark: PreviewMark::Unchanged, + proposal_id: None, + }), + (None, None) => {} + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unit(id: &str, position: f64, in_force: Option<&str>) -> UnitState { + UnitState { + id: id.into(), + position, + inertia: InertiaPreset::Standard, + in_force: in_force.map(|b| VersionText { + id: format!("{id}-v"), + title: id.into(), + body: b.into(), + tally: Tally::default(), + }), + } + } + + fn prop(id: &str, unit_id: &str, kind: ProposalKind, body: &str, for_votes: u32, against: u32) -> ProposalState { + ProposalState { + id: id.into(), + unit_id: unit_id.into(), + kind, + title: unit_id.into(), + body: body.into(), + tally: Tally { for_votes, against }, + submitted_at: format!("2026-10-21T00:00:0{}Z", id.len() % 10), + blocked: false, + } + } + + const W: u32 = 5745; + const C: f64 = 50.0; + + #[test] + fn nothing_passes_at_zero_votes() { + let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None)]; + let props = [prop("p1", "E3", ProposalKind::Text, "connaître", 0, 0)]; + assert!(adoptions(&units, &props, W, C).expect("valid").is_empty()); + } + + #[test] + fn the_floor_holds_then_releases() { + let units = [unit("E3", 7.0, None)]; + let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 49, 0)]; + assert!(adoptions(&units, &few, W, C).expect("valid").is_empty()); + let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 50, 0)]; + assert_eq!(adoptions(&units, &enough, W, C).expect("valid"), vec!["p1".to_string()]); + } + + #[test] + fn rivals_on_one_unit_yield_one_winner() { + let units = [unit("E3", 7.0, None)]; + let props = [ + prop("a", "E3", ProposalKind::Text, "variante a", 60, 0), + prop("bb", "E3", ProposalKind::Text, "variante b", 70, 0), + ]; + assert_eq!(adoptions(&units, &props, W, C).expect("valid"), vec!["bb".to_string()]); + } + + #[test] + fn inertia_and_content_are_independent_dimensions() { + let units = [unit("E1", 4.0, Some("unicité"))]; + let props = [ + prop("t", "E1", ProposalKind::Text, "unicité reformulée", 60, 0), + prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 60, 0), + ]; + let won = adoptions(&units, &props, W, C).expect("valid"); + assert_eq!(won, vec!["t".to_string(), "i".to_string()]); + } + + #[test] + fn inertia_change_is_voted_one_level_up() { + let u = unit("E1", 4.0, Some("x")); + let p = params_for(&u, ProposalKind::Inertia(InertiaPreset::Low), C); + assert_eq!(p.majority_pct, InertiaPreset::High.majority_pct()); + assert_eq!(p.gradient, InertiaPreset::High.gradient()); + assert_eq!(params_for(&u, ProposalKind::Text, C).gradient, InertiaPreset::Standard.gradient()); + } + + #[test] + fn blocked_or_ineffective_never_pass() { + let units = [unit("E1", 4.0, Some("unicité"))]; + let mut blocked = prop("t", "E1", ProposalKind::Text, "autre", 80, 0); + blocked.blocked = true; + let same = prop("s", "E1", ProposalKind::Text, "unicité", 80, 0); + let same_inertia = prop("i", "E1", ProposalKind::Inertia(InertiaPreset::Standard), "", 80, 0); + assert!(adoptions(&units, &[blocked, same, same_inertia], W, C).expect("valid").is_empty()); + } + + #[test] + fn a_rival_must_beat_the_version_in_force() { + let mut units = [unit("E1", 4.0, Some("unicité"))]; + // The seed a minima has no vote: the first rival that reaches its threshold wins. + let rival = [prop("r", "E1", ProposalKind::Text, "unicité reformulée", 50, 0)]; + assert_eq!(adoptions(&units, &rival, W, C).expect("valid"), vec!["r".to_string()]); + // A version in force by vote, margin 60 − 50 = 10: a rival at margin 5 waits. + if let Some(v) = units[0].in_force.as_mut() { + v.tally = Tally { for_votes: 60, against: 0 }; + } + let weaker = [prop("w", "E1", ProposalKind::Text, "autre", 55, 0)]; + assert!(adoptions(&units, &weaker, W, C).expect("valid").is_empty()); + let stronger = [prop("s", "E1", ProposalKind::Text, "autre", 70, 0)]; + assert_eq!(adoptions(&units, &stronger, W, C).expect("valid"), vec!["s".to_string()]); + // The same rule holds for a removal; inertia does not compete with the text. + let removal = [prop("x", "E1", ProposalKind::Remove, "", 55, 0)]; + assert!(adoptions(&units, &removal, W, C).expect("valid").is_empty()); + let inertia = [prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 55, 0)]; + assert_eq!(adoptions(&units, &inertia, W, C).expect("valid"), vec!["i".to_string()]); + } + + #[test] + fn previews() { + let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None), unit("E1", 4.0, Some("unicité"))]; + let props = [ + prop("well", "E3", ProposalKind::Text, "connaître", 3, 0), + prop("weak", "E1", ProposalKind::Remove, "", 1, 5), + ]; + let in_force = preview(&units, &props, W, C, PreviewMode::InForce).expect("valid"); + assert_eq!(in_force.iter().map(|u| u.unit_id.as_str()).collect::>(), ["I1", "E1"]); + + let likely = preview(&units, &props, W, C, PreviewMode::WellRated).expect("valid"); + assert_eq!( + likely.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::>(), + [("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Unchanged), ("E3", PreviewMark::Added),] + ); + + let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid"); + assert_eq!( + all.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::>(), + [("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Removed), ("E3", PreviewMark::Added),] + ); + } + + #[test] + fn all_mode_prefers_the_most_supported_rival() { + let units = [unit("E3", 7.0, None)]; + let props = [ + prop("a", "E3", ProposalKind::Text, "variante a", 0, 0), + prop("bb", "E3", ProposalKind::Text, "variante b", 1, 3), + ]; + let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid"); + assert_eq!(all[0].proposal_id.as_deref(), Some("bb")); + } +} diff --git a/crates/ld-engine/src/edit.rs b/crates/ld-engine/src/edit.rs new file mode 100644 index 0000000..c6882e3 --- /dev/null +++ b/crates/ld-engine/src/edit.rs @@ -0,0 +1,243 @@ +//! Edit classification — a typo fix is not a decision. +//! +//! Once a proposal is under vote, its author may still correct it. The engine +//! tells two kinds of corrections apart: +//! +//! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most +//! two misspelt words fixed (edit distance ≤ 2, no number, no negation +//! touched). The votes stay; the correction is shown with its diff. +//! - **substantive**: anything else. It opens a new revision and every vote +//! cast on the previous one waits for its author to reconfirm it. +//! +//! The rule is deliberately conservative: when in doubt, it is substantive. + +use serde::{Deserialize, Serialize}; +use unicode_normalization::UnicodeNormalization; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EditKind { + Unchanged, + Typographic, + Substantive, +} + +/// Words whose presence flips a meaning; touching one is never a typo. +const NEGATIONS: &[&str] = &[ + // fr + "ne", "n", "pas", "jamais", "non", "sans", "aucun", "aucune", "ni", "plus", "rien", "nul", "nulle", "personne", + // en + "not", "no", "never", "without", "none", "nor", // es + "nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada", +]; + +const MAX_TYPO_WORDS: usize = 2; +const MAX_TYPO_DISTANCE: usize = 2; + +/// Lowercase, strip diacritics. +fn fold(s: &str) -> String { + s.nfd().filter(|c| !is_combining(*c)).flat_map(char::to_lowercase).collect() +} + +fn is_combining(c: char) -> bool { + matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F) +} + +/// Letters and digits only, folded: what survives every typographic change. +fn skeleton(s: &str) -> String { + fold(s).chars().filter(|c| c.is_alphanumeric()).collect() +} + +/// Folded words (runs of letters/digits). +fn words(s: &str) -> Vec { + fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect() +} + +pub fn classify(old: &str, new: &str) -> EditKind { + if old == new { + return EditKind::Unchanged; + } + if skeleton(old) == skeleton(new) { + return EditKind::Typographic; + } + let (a, b) = (words(old), words(new)); + if a.len() != b.len() { + return EditKind::Substantive; + } + let mut changed = 0; + for (x, y) in a.iter().zip(&b) { + if x == y { + continue; + } + changed += 1; + let touches_meaning = changed > MAX_TYPO_WORDS + || x.chars().any(|c| c.is_ascii_digit()) + || y.chars().any(|c| c.is_ascii_digit()) + || NEGATIONS.contains(&x.as_str()) + || NEGATIONS.contains(&y.as_str()) + || x.chars().count().min(y.chars().count()) < 3 + || levenshtein(x, y) > MAX_TYPO_DISTANCE; + if touches_meaning { + return EditKind::Substantive; + } + } + EditKind::Typographic +} + +pub fn levenshtein(a: &str, b: &str) -> usize { + let b: Vec = b.chars().collect(); + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur = vec![0; b.len() + 1]; + for (i, ca) in a.chars().enumerate() { + cur[0] = i + 1; + for (j, cb) in b.iter().enumerate() { + let cost = usize::from(ca != *cb); + cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1); + } + std::mem::swap(&mut prev, &mut cur); + } + prev[b.len()] +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DiffOp { + Same, + Added, + Removed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiffPart { + pub op: DiffOp, + pub text: String, +} + +/// Tokens that keep every character: words, and the runs between them. +fn tokens(s: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut start = 0; + let mut in_word: Option = None; + for (i, c) in s.char_indices() { + let w = c.is_alphanumeric(); + if in_word.is_some_and(|prev| prev != w) { + out.push(&s[start..i]); + start = i; + } + in_word = Some(w); + } + if start < s.len() { + out.push(&s[start..]); + } + out +} + +const MAX_DIFF_TOKENS: usize = 4000; + +/// Word-level diff for display. Concatenating `Same` + `Added` gives `new`, +/// `Same` + `Removed` gives `old`. +pub fn diff(old: &str, new: &str) -> Vec { + let (a, b) = (tokens(old), tokens(new)); + if a.len() * b.len() > MAX_DIFF_TOKENS * MAX_DIFF_TOKENS / 4 { + return vec![ + DiffPart { op: DiffOp::Removed, text: old.to_owned() }, + DiffPart { op: DiffOp::Added, text: new.to_owned() }, + ]; + } + // LCS table, suffix form. + let (n, m) = (a.len(), b.len()); + let mut lcs = vec![vec![0u32; m + 1]; n + 1]; + for i in (0..n).rev() { + for j in (0..m).rev() { + lcs[i][j] = if a[i] == b[j] { lcs[i + 1][j + 1] + 1 } else { lcs[i + 1][j].max(lcs[i][j + 1]) }; + } + } + let mut parts: Vec = Vec::new(); + let mut push = |op: DiffOp, text: &str| match parts.last_mut() { + Some(last) if last.op == op => last.text.push_str(text), + _ => parts.push(DiffPart { op, text: text.to_owned() }), + }; + let (mut i, mut j) = (0, 0); + while i < n && j < m { + if a[i] == b[j] { + push(DiffOp::Same, a[i]); + i += 1; + j += 1; + } else if lcs[i + 1][j] >= lcs[i][j + 1] { + push(DiffOp::Removed, a[i]); + i += 1; + } else { + push(DiffOp::Added, b[j]); + j += 1; + } + } + for t in &a[i..] { + push(DiffOp::Removed, t); + } + for t in &b[j..] { + push(DiffOp::Added, t); + } + parts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_glued_word_of_the_seed_is_a_typo() { + let old = "Toute Certification doit s'accompagner de latransmission de ce document"; + let new = "Toute Certification doit s'accompagner de la transmission de ce document"; + assert_eq!(classify(old, new), EditKind::Typographic); + } + + #[test] + fn accents_case_punctuation_emphasis() { + assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier !"), EditKind::Typographic); + assert_eq!(classify("un **être humain**", "un être humain"), EditKind::Typographic); + assert_eq!(classify("same", "same"), EditKind::Unchanged); + } + + #[test] + fn misspelt_words() { + assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic); + assert_eq!(classify("toute certifcation", "toute certification"), EditKind::Typographic); + // « personne » is also a negation (ne … personne): conservative. + assert_eq!(classify("la persone", "la personne"), EditKind::Substantive); + } + + #[test] + fn meaning_changes() { + // A number. + assert_eq!(classify("tous les 5 jours", "tous les 6 jours"), EditKind::Substantive); + // A negation, even one letter apart. + assert_eq!(classify("je peux certifier", "je ne peux certifier"), EditKind::Substantive); + assert_eq!(classify("plus de deux", "pas de deux"), EditKind::Substantive); + // A different word. + assert_eq!(classify("au moins deux canaux", "au moins trois canaux"), EditKind::Substantive); + // Three fixes are no longer a typo. + assert_eq!(classify("aaaa bbbb cccc", "aaab bbbc cccd"), EditKind::Substantive); + // A word added. + assert_eq!(classify("en visio", "en visio directe"), EditKind::Substantive); + } + + #[test] + fn diff_reconstructs_both_sides() { + let old = "J'ai rencontré la personne physiquement (préférable)."; + let new = "J'ai rencontré la personne en visio (préférable)."; + let parts = diff(old, new); + let rebuild = |keep: DiffOp| -> String { + parts.iter().filter(|p| p.op == DiffOp::Same || p.op == keep).map(|p| p.text.as_str()).collect() + }; + assert_eq!(rebuild(DiffOp::Removed), old); + assert_eq!(rebuild(DiffOp::Added), new); + assert!(parts.iter().any(|p| p.op == DiffOp::Removed && p.text.contains("physiquement"))); + } + + #[test] + fn levenshtein_basics() { + assert_eq!(levenshtein("kitten", "sitting"), 3); + assert_eq!(levenshtein("", "abc"), 3); + assert_eq!(levenshtein("été", "ete"), 2); + } +} diff --git a/crates/ld-engine/src/lib.rs b/crates/ld-engine/src/lib.rs new file mode 100644 index 0000000..2b90b71 --- /dev/null +++ b/crates/ld-engine/src/lib.rs @@ -0,0 +1,14 @@ +//! Pure engines of libreDecision — no IO, no clock, no randomness. +//! +//! Everything that decides an outcome lives here so it can be tested in +//! isolation and ported verbatim: the inertial threshold (parity with the +//! Python/TypeScript reference of the v2 maquette), the edit classifier that +//! separates a typo fix from a change of meaning, the rule-based editing +//! assistant, and the composition of a document under permanent vote. + +pub mod assist; +pub mod compose; +pub mod edit; +pub mod threshold; + +pub use threshold::{FormulaParams, InertiaPreset, Tally}; diff --git a/crates/ld-engine/src/threshold.rs b/crates/ld-engine/src/threshold.rs new file mode 100644 index 0000000..95f0bbe --- /dev/null +++ b/crates/ld-engine/src/threshold.rs @@ -0,0 +1,285 @@ +//! Inertial threshold for binary votes — the Ğ1 "g1vote" formula. +//! +//! ```text +//! R = C + B^W + (M + (1 − M)(1 − (T/W)^G)) · max(0, T − C) +//! ``` +//! +//! R is the minimum number of *for* votes; a proposal is adopted when +//! `for ≥ ⌈R⌉`. W is the size of the electorate, T the votes cast (for + +//! against). At low participation the formula asks for near-unanimity, at full +//! participation the simple majority M suffices. C is the minimal number of +//! *for* votes below which nothing passes — the guard of a permanent vote +//! (Yvv, 2026-10-21: « Nombre minimal de votants »). +//! +//! Parity: identical to `maquette/frontend/app/engine/threshold.ts` and the +//! Python reference, with one deliberate guard — the participation ratio is +//! capped at 1, so a stale W can never lower the bar below M. + +use serde::{Deserialize, Serialize}; + +/// The four inertia levels of a modular text. Wiring of the v2 maquette: +/// low = M50 G0.1, standard = M50 G0.2, high = M60 G0.4, max = M66 G0.6 (B = 0.1). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum InertiaPreset { + Low, + Standard, + High, + Max, +} + +impl InertiaPreset { + pub const ALL: [Self; 4] = [Self::Low, Self::Standard, Self::High, Self::Max]; + + pub fn majority_pct(self) -> f64 { + match self { + Self::Low | Self::Standard => 50.0, + Self::High => 60.0, + Self::Max => 66.0, + } + } + + pub fn gradient(self) -> f64 { + match self { + Self::Low => 0.1, + Self::Standard => 0.2, + Self::High => 0.4, + Self::Max => 0.6, + } + } + + /// « Inertie + » : one level up, capped at max. A change of inertia is + /// itself voted at the raised level of the unit it touches. + pub fn raised(self) -> Self { + match self { + Self::Low => Self::Standard, + Self::Standard => Self::High, + Self::High | Self::Max => Self::Max, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Low => "low", + Self::Standard => "standard", + Self::High => "high", + Self::Max => "max", + } + } + + pub fn parse(raw: &str) -> Option { + Self::ALL.into_iter().find(|p| p.as_str() == raw) + } +} + +/// The letters of the formula, named. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct FormulaParams { + /// M, in percent (0–100). + pub majority_pct: f64, + /// G. + pub gradient: f64, + /// B. + pub base: f64, + /// C — minimal number of *for* votes. + pub constant: f64, +} + +impl FormulaParams { + pub const DEFAULT_BASE: f64 = 0.1; + + pub fn for_inertia(preset: InertiaPreset, constant: f64) -> Self { + Self { majority_pct: preset.majority_pct(), gradient: preset.gradient(), base: Self::DEFAULT_BASE, constant } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThresholdError { + EmptyElectorate, + MajorityOutOfRange, +} + +/// The required ratio of *for* votes among the votes cast beyond C. +pub fn required_ratio(electorate: u32, cast: u32, params: &FormulaParams) -> f64 { + let m = params.majority_pct / 100.0; + let participation = (f64::from(cast) / f64::from(electorate.max(1))).min(1.0); + m + (1.0 - m) * (1.0 - participation.powf(params.gradient)) +} + +/// ⌈R⌉ — the minimum number of *for* votes for adoption. +pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Result { + if electorate == 0 { + return Err(ThresholdError::EmptyElectorate); + } + if !(0.0..=100.0).contains(¶ms.majority_pct) { + return Err(ThresholdError::MajorityOutOfRange); + } + let c = params.constant; + let offset = params.base.powf(f64::from(electorate)); + let raw = if cast == 0 { + c + offset + } else { + c + offset + required_ratio(electorate, cast, params) * (f64::from(cast) - c).max(0.0) + }; + // `raw` is finite and non-negative for any valid input; saturate rather than wrap. + Ok(clamp_ceil(raw)) +} + +fn clamp_ceil(raw: f64) -> u32 { + let ceiled = raw.ceil(); + if ceiled <= 0.0 { + 0 + } else if ceiled >= f64::from(u32::MAX) { + u32::MAX + } else { + // Exact: `ceiled` is an integer in (0, u32::MAX). + ceiled as u32 + } +} + +/// Votes counted on one proposal. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Tally { + #[serde(rename = "for")] + pub for_votes: u32, + pub against: u32, +} + +impl Tally { + pub fn cast(&self) -> u32 { + self.for_votes.saturating_add(self.against) + } +} + +/// Where a proposal stands, in words a screen can say. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Evaluation { + pub cast: u32, + /// ⌈R⌉ at the current participation. + pub required: u32, + /// `for ≥ ⌈R⌉` with at least one vote cast. + pub reached: bool, + /// *for* votes still missing (0 once reached). + pub missing: u32, + /// The majority the inertia asks for at this participation. + pub required_ratio: f64, + /// « Bien cotée » : it holds the majority its inertia asks for, it only + /// lacks voters (C ignored). Used by the "if the well-rated pass" preview. + pub well_rated: bool, +} + +pub fn evaluate(tally: Tally, electorate: u32, params: &FormulaParams) -> Result { + let cast = tally.cast(); + let required = wot_threshold(electorate, cast, params)?; + let without_floor = FormulaParams { constant: 0.0, ..*params }; + let required_without_floor = wot_threshold(electorate, cast, &without_floor)?; + let reached = cast > 0 && tally.for_votes >= required; + Ok(Evaluation { + cast, + required, + reached, + missing: required.saturating_sub(tally.for_votes), + required_ratio: required_ratio(electorate, cast, params), + well_rated: cast > 0 && tally.for_votes >= required_without_floor, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn g1vote(m: f64, g: f64) -> FormulaParams { + FormulaParams { majority_pct: m, gradient: g, base: 0.1, constant: 0.0 } + } + + #[test] + fn reference_case_of_the_smith_vote() { + // Engagement forgeron v2.0.0: W 7224, 97 for, 23 against, M50 B.1 G.2 → 94. + let p = g1vote(50.0, 0.2); + assert_eq!(wot_threshold(7224, 120, &p), Ok(94)); + let e = evaluate(Tally { for_votes: 97, against: 23 }, 7224, &p).expect("valid"); + assert!(e.reached); + assert_eq!(e.missing, 0); + } + + #[test] + fn parity_with_the_typescript_reference() { + // Values computed by maquette/frontend/app/engine/threshold.ts. + let p = g1vote(50.0, 0.2); + assert_eq!(wot_threshold(7224, 0, &p), Ok(0)); + assert_eq!(wot_threshold(7224, 1, &p), Ok(1)); + assert_eq!(wot_threshold(100, 100, &p), Ok(50)); + assert_eq!(wot_threshold(5, 5, &p), Ok(3)); + let high = g1vote(60.0, 0.4); + assert_eq!(wot_threshold(7224, 120, &high), Ok(111)); + } + + #[test] + fn one_vote_passes_without_floor_and_never_with_it() { + // The trap of a permanent vote: a lone "for" meets the inertial bar. + let bare = FormulaParams::for_inertia(InertiaPreset::Standard, 0.0); + assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &bare).expect("valid").reached); + let guarded = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0); + let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &guarded).expect("valid"); + assert!(!e.reached); + assert_eq!(e.required, 50); + assert_eq!(e.missing, 49); + assert!(e.well_rated, "unanimous, it only lacks voters"); + } + + #[test] + fn floor_is_a_minimum_of_for_votes() { + let p = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0); + // 50 unanimous votes reach it, 49 do not. + assert!(evaluate(Tally { for_votes: 50, against: 0 }, 5745, &p).expect("valid").reached); + assert!(!evaluate(Tally { for_votes: 49, against: 0 }, 5745, &p).expect("valid").reached); + // Beyond the floor the inertial majority applies to the rest. + let e = evaluate(Tally { for_votes: 55, against: 5 }, 5745, &p).expect("valid"); + assert!(!e.reached, "near-unanimity is asked of the votes beyond C"); + } + + #[test] + fn no_vote_is_never_reached() { + let p = FormulaParams::for_inertia(InertiaPreset::Low, 0.0); + let e = evaluate(Tally::default(), 3, &p).expect("valid"); + assert!(!e.reached); + assert!(!e.well_rated); + } + + #[test] + fn small_group_needs_everyone() { + // Test group of two people, C = 1: both must vote for. + let p = FormulaParams::for_inertia(InertiaPreset::Standard, 1.0); + assert!(!evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached); + assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached); + } + + #[test] + fn stale_electorate_cannot_lower_the_bar_below_majority() { + let p = g1vote(50.0, 0.2); + // 30 votes cast for an electorate believed to be 10: the reference + // would ask 12 (ratio below M), the cap keeps the simple majority: + // ⌈B^W + 0.5 × 30⌉ = 16, the tiny B^W offset rounding up. + let r = wot_threshold(10, 30, &p).expect("valid"); + assert_eq!(r, 16); + } + + #[test] + fn inertia_levels_rise_and_cap() { + assert_eq!(InertiaPreset::Low.raised(), InertiaPreset::Standard); + assert_eq!(InertiaPreset::Standard.raised(), InertiaPreset::High); + assert_eq!(InertiaPreset::Max.raised(), InertiaPreset::Max); + for p in InertiaPreset::ALL { + assert_eq!(InertiaPreset::parse(p.as_str()), Some(p)); + } + } + + #[test] + fn errors() { + let p = g1vote(50.0, 0.2); + assert_eq!(wot_threshold(0, 1, &p), Err(ThresholdError::EmptyElectorate)); + let bad = g1vote(120.0, 0.2); + assert_eq!(wot_threshold(10, 1, &bad), Err(ThresholdError::MajorityOutOfRange)); + } +} diff --git a/crates/ld-server/Cargo.toml b/crates/ld-server/Cargo.toml new file mode 100644 index 0000000..230c91a --- /dev/null +++ b/crates/ld-server/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "ld-server" +description = "libreDecision server: API, Ğ1 sign-in, storage, publication of adopted texts, static web." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[dependencies] +ld-engine = { path = "../ld-engine" } +trustin-access = { path = "../trustin-access" } + +anyhow = "1" +axum = { version = "0.8", features = ["macros"] } +base64 = "0.22" +hex = "0.4" +rand = "0.9" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] } +thiserror = "2" +time = { version = "0.3", features = ["formatting", "parsing", "macros"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time", "process", "fs"] } +tower-http = { version = "0.6", features = ["fs", "trace", "compression-gzip", "set-header", "limit"] } +tower = { version = "0.5", features = ["util"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +twox-hash = { version = "2", default-features = false, features = ["xxhash64"] } +url = "2" + +[dev-dependencies] +http-body-util = "0.1" + +[lints] +workspace = true diff --git a/crates/ld-server/migrations/0001_init.sql b/crates/ld-server/migrations/0001_init.sql new file mode 100644 index 0000000..96357fe --- /dev/null +++ b/crates/ld-server/migrations/0001_init.sql @@ -0,0 +1,181 @@ +-- libreDecision v3 — initial schema. Runtime data: never dropped, never rewritten. +-- Dates are RFC 3339 UTC strings (sortable). + +CREATE TABLE identities ( + id TEXT PRIMARY KEY, -- idty: | inv: | dev: + kind TEXT NOT NULL, -- g1 | invite | dev + display_name TEXT NOT NULL, + address TEXT, -- SS58 seen at the last Ğ1 sign-in, display only + g1_member INTEGER NOT NULL DEFAULT 0, + admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + last_seen_at TEXT +); + +CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, + identity_id TEXT NOT NULL REFERENCES identities(id), + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE TABLE invitations ( + token_hash TEXT PRIMARY KEY, + label TEXT NOT NULL, + admin INTEGER NOT NULL DEFAULT 0, + groups TEXT NOT NULL DEFAULT '[]', -- JSON array of group slugs granted on acceptance + created_by TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + identity_id TEXT +); + +CREATE TABLE groups ( + slug TEXT PRIMARY KEY, + name TEXT NOT NULL, + parent TEXT, + policy TEXT NOT NULL, -- g1-members | managed + hidden INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL +); + +CREATE TABLE memberships ( + group_slug TEXT NOT NULL REFERENCES groups(slug), + identity_id TEXT NOT NULL REFERENCES identities(id), + granted_by TEXT, + granted_at TEXT NOT NULL, + PRIMARY KEY (group_slug, identity_id) +); + +CREATE TABLE documents ( + id TEXT PRIMARY KEY, + group_slug TEXT NOT NULL REFERENCES groups(slug), + slug TEXT NOT NULL, + title TEXT NOT NULL, -- JSON {lang: text} + summary TEXT NOT NULL, -- JSON {lang: text} + source_lang TEXT NOT NULL, + vote_floor REAL NOT NULL, -- C, minimal number of "for" votes + publish_path TEXT, -- path in the texts repository; NULL = never published + created_at TEXT NOT NULL, + UNIQUE (group_slug, slug) +); + +CREATE TABLE units ( + id TEXT PRIMARY KEY, + doc_id TEXT NOT NULL REFERENCES documents(id), + code TEXT NOT NULL, + position REAL NOT NULL, + inertia TEXT NOT NULL, -- low | standard | high | max + in_force_id TEXT, -- proposals.id of the version in force, NULL = empty slot + created_at TEXT NOT NULL +); +CREATE INDEX units_doc ON units(doc_id); + +-- A proposal is a version of a unit: proposed, in force, or past. +CREATE TABLE proposals ( + id TEXT PRIMARY KEY, + unit_id TEXT NOT NULL REFERENCES units(id), + kind TEXT NOT NULL, -- text | remove | inertia + inertia_target TEXT, + title TEXT NOT NULL, + body TEXT NOT NULL, + status TEXT NOT NULL, -- draft | open | in_force | superseded | withdrawn + origin TEXT NOT NULL, -- seed | member + label TEXT, + author_id TEXT REFERENCES identities(id), + revision INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + submitted_at TEXT, + entered_at TEXT, -- entered into force by vote; NULL for a seed a minima + left_at TEXT, + confirmed_at TEXT -- a seed a minima that later reached its threshold +); +CREATE INDEX proposals_unit ON proposals(unit_id); + +CREATE TABLE revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + proposal_id TEXT NOT NULL REFERENCES proposals(id), + revision INTEGER NOT NULL, + kind TEXT NOT NULL, -- initial | typo | substantive + title TEXT NOT NULL, + body TEXT NOT NULL, + author_id TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX revisions_proposal ON revisions(proposal_id); + +CREATE TABLE votes ( + proposal_id TEXT NOT NULL REFERENCES proposals(id), + identity_id TEXT NOT NULL REFERENCES identities(id), + choice TEXT NOT NULL, -- for | against + revision INTEGER NOT NULL, -- counted only while equal to proposals.revision + cast_at TEXT NOT NULL, + PRIMARY KEY (proposal_id, identity_id) +); + +CREATE TABLE signals ( + id TEXT PRIMARY KEY, + proposal_id TEXT NOT NULL REFERENCES proposals(id), + target_proposal_id TEXT NOT NULL REFERENCES proposals(id), + reason TEXT NOT NULL, + author_id TEXT NOT NULL REFERENCES identities(id), + created_at TEXT NOT NULL, + lifted_at TEXT, + lifted_by TEXT -- identity id, or 'revision' when the text changed +); + +CREATE TABLE translations ( + proposal_id TEXT NOT NULL REFERENCES proposals(id), + lang TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + source_hash TEXT NOT NULL, -- hash of the source title + body it translates + origin TEXT NOT NULL, -- seed | member + updated_by TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (proposal_id, lang) +); + +CREATE TABLE adoptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + proposal_id TEXT NOT NULL, + unit_id TEXT NOT NULL, + doc_id TEXT NOT NULL, + kind TEXT NOT NULL, + votes_for INTEGER NOT NULL, + votes_against INTEGER NOT NULL, + electorate INTEGER NOT NULL, + required INTEGER NOT NULL, + params TEXT NOT NULL, + at TEXT NOT NULL +); + +CREATE TABLE outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + doc_id TEXT NOT NULL, + reason TEXT NOT NULL, + created_at TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + next_at TEXT NOT NULL, + done_at TEXT, + commit_sha TEXT +); + +CREATE TABLE feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + identity_id TEXT, + route TEXT NOT NULL, + body TEXT NOT NULL, + context TEXT, + created_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'new' +); + +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/crates/ld-server/src/access.rs b/crates/ld-server/src/access.rs new file mode 100644 index 0000000..d24559c --- /dev/null +++ b/crates/ld-server/src/access.rs @@ -0,0 +1,243 @@ +//! trustinWallet — access management, for its administrators only — and the +//! « Retour » channel that turns usage tests into controls. + +use crate::AppState; +use crate::auth::{self, IDENTITY_COLUMNS, IdentityRow, User}; +use crate::error::{AppError, AppResult}; +use crate::util; +use axum::Json; +use axum::extract::{Path, State}; +use serde::Deserialize; +use serde_json::json; + +fn admin_only(user: &IdentityRow) -> AppResult<()> { + if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) } +} + +pub async fn overview(State(state): State, User(user): User) -> AppResult> { + admin_only(&user)?; + #[derive(sqlx::FromRow)] + struct Member { + group_slug: String, + identity_id: String, + granted_at: String, + } + let groups: Vec<(String, String, String, bool)> = + sqlx::query_as("SELECT slug, name, policy, hidden FROM groups ORDER BY position").fetch_all(&state.db).await?; + let members = sqlx::query_as::<_, Member>("SELECT group_slug, identity_id, granted_at FROM memberships") + .fetch_all(&state.db) + .await?; + let identities = sqlx::query_as::<_, IdentityRow>(&format!( + "SELECT {IDENTITY_COLUMNS} FROM identities ORDER BY admin DESC, display_name COLLATE NOCASE" + )) + .fetch_all(&state.db) + .await?; + let last_seen: Vec<(String, Option)> = + sqlx::query_as("SELECT id, last_seen_at FROM identities").fetch_all(&state.db).await?; + let invitations: Vec<(String, String, bool, String, String, Option)> = sqlx::query_as( + "SELECT token_hash, label, admin, groups, expires_at, identity_id FROM invitations + WHERE used_at IS NULL AND expires_at > ? ORDER BY created_at DESC", + ) + .bind(util::now()) + .fetch_all(&state.db) + .await?; + Ok(Json(json!({ + "groups": groups.iter().map(|(slug, name, policy, hidden)| json!({ + "slug": slug, "name": name, "policy": policy, "hidden": hidden, + "members": members.iter().filter(|m| &m.group_slug == slug) + .map(|m| json!({ "identityId": m.identity_id, "grantedAt": m.granted_at })).collect::>(), + })).collect::>(), + "identities": identities.iter().map(|i| json!({ + "id": i.id, "kind": i.kind, "displayName": i.display_name, "g1Member": i.g1_member, "admin": i.admin, + "lastSeenAt": last_seen.iter().find(|(id, _)| id == &i.id).and_then(|(_, at)| at.clone()), + })).collect::>(), + "invitations": invitations.iter().map(|(hash, label, admin, groups, expires, identity)| json!({ + "id": hash, "label": label, "admin": admin, + "groups": serde_json::from_str::>(groups).unwrap_or_default(), + "expiresAt": expires, "identityId": identity, + })).collect::>(), + }))) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GrantBody { + identity_id: String, +} + +pub async fn grant( + State(state): State, + User(user): User, + Path(slug): Path, + Json(b): Json, +) -> AppResult> { + admin_only(&user)?; + auth::load_identity(&state, &b.identity_id).await?; + let done = sqlx::query( + "INSERT OR IGNORE INTO memberships (group_slug, identity_id, granted_by, granted_at) + SELECT slug, ?, ?, ? FROM groups WHERE slug = ?", + ) + .bind(&b.identity_id) + .bind(&user.id) + .bind(util::now()) + .bind(&slug) + .execute(&state.db) + .await?; + tracing::info!(group = %slug, identity = %b.identity_id, by = %user.id, "access granted"); + Ok(Json(json!({ "granted": done.rows_affected() == 1 }))) +} + +pub async fn revoke( + State(state): State, + User(user): User, + Path((slug, identity)): Path<(String, String)>, +) -> AppResult> { + admin_only(&user)?; + let done = sqlx::query("DELETE FROM memberships WHERE group_slug = ? AND identity_id = ?") + .bind(&slug) + .bind(&identity) + .execute(&state.db) + .await?; + tracing::info!(group = %slug, identity = %identity, by = %user.id, "access revoked"); + Ok(Json(json!({ "revoked": done.rows_affected() == 1 }))) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InviteBody { + label: String, + #[serde(default)] + admin: bool, + #[serde(default)] + groups: Vec, + identity_id: Option, +} + +/// The link is returned once and never stored in clear. +pub async fn invite( + State(state): State, + User(user): User, + Json(b): Json, +) -> AppResult> { + admin_only(&user)?; + let label = b.label.trim(); + if label.is_empty() || label.chars().count() > 60 { + return Err(AppError::Invalid("label")); + } + if let Some(id) = &b.identity_id { + auth::load_identity(&state, id).await?; + } + let token = + auth::create_invitation(&state, label, b.admin, &b.groups, Some(&user.id), b.identity_id.as_deref()).await?; + Ok(Json(json!({ "link": auth::invitation_link(&state, &token) }))) +} + +pub async fn cancel_invitation( + State(state): State, + User(user): User, + Path(hash): Path, +) -> AppResult> { + admin_only(&user)?; + sqlx::query("UPDATE invitations SET expires_at = ? WHERE token_hash = ? AND used_at IS NULL") + .bind(util::now()) + .bind(&hash) + .execute(&state.db) + .await?; + Ok(Json(json!({ "ok": true }))) +} + +#[derive(Deserialize)] +pub struct AdminBody { + admin: bool, +} + +pub async fn set_admin( + State(state): State, + User(user): User, + Path(id): Path, + Json(b): Json, +) -> AppResult> { + admin_only(&user)?; + auth::load_identity(&state, &id).await?; + if !b.admin { + let others: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE admin = 1 AND id != ?") + .bind(&id) + .fetch_one(&state.db) + .await?; + if others == 0 { + return Err(AppError::Conflict("last_admin")); + } + } + sqlx::query("UPDATE identities SET admin = ? WHERE id = ?").bind(b.admin).bind(&id).execute(&state.db).await?; + tracing::info!(identity = %id, admin = b.admin, by = %user.id, "administrator changed"); + Ok(Json(json!({ "admin": b.admin }))) +} + +#[derive(Deserialize)] +pub struct FeedbackBody { + route: String, + body: String, + context: Option, +} + +pub async fn send_feedback( + State(state): State, + User(user): User, + Json(b): Json, +) -> AppResult> { + let body = b.body.trim(); + if body.is_empty() || body.chars().count() > 4000 || b.route.len() > 300 { + return Err(AppError::Invalid("body")); + } + let context = b.context.map(|c| c.to_string()).filter(|c| c.len() < 4000); + sqlx::query("INSERT INTO feedback (identity_id, route, body, context, created_at) VALUES (?, ?, ?, ?, ?)") + .bind(&user.id) + .bind(&b.route) + .bind(body) + .bind(context) + .bind(util::now()) + .execute(&state.db) + .await?; + Ok(Json(json!({ "ok": true }))) +} + +pub async fn feedback_list(State(state): State, User(user): User) -> AppResult> { + admin_only(&user)?; + #[derive(sqlx::FromRow, serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct Row { + id: i64, + author: Option, + route: String, + body: String, + context: Option, + created_at: String, + status: String, + } + let rows = sqlx::query_as::<_, Row>( + "SELECT f.id, i.display_name AS author, f.route, f.body, f.context, f.created_at, f.status + FROM feedback f LEFT JOIN identities i ON i.id = f.identity_id ORDER BY f.id DESC LIMIT 500", + ) + .fetch_all(&state.db) + .await?; + Ok(Json(json!({ "feedback": rows }))) +} + +#[derive(Deserialize)] +pub struct StatusBody { + status: String, +} + +pub async fn feedback_status( + State(state): State, + User(user): User, + Path(id): Path, + Json(b): Json, +) -> AppResult> { + admin_only(&user)?; + if !matches!(b.status.as_str(), "new" | "seen" | "done") { + return Err(AppError::Invalid("status")); + } + sqlx::query("UPDATE feedback SET status = ? WHERE id = ?").bind(&b.status).bind(id).execute(&state.db).await?; + Ok(Json(json!({ "ok": true }))) +} diff --git a/crates/ld-server/src/adoption.rs b/crates/ld-server/src/adoption.rs new file mode 100644 index 0000000..b61c7ca --- /dev/null +++ b/crates/ld-server/src/adoption.rs @@ -0,0 +1,185 @@ +//! Entering into force — automatic, as soon as a proposal reaches its +//! threshold (Yvv, 2026-10-21: « Dès qu'une proposition atteint le score, elle +//! remonte en vigueur »). The human gesture is the vote itself; the engine +//! only counts. Every entry is recorded with its numbers and queued for +//! publication to the texts repository. + +use crate::AppState; +use crate::docs::{self, DocRow, DocState}; +use crate::error::AppResult; +use crate::util; +use ld_engine::compose::{self, ProposalKind, UnitState}; +use serde::Serialize; +use std::time::Duration; +use tokio::sync::Mutex; + +/// One adoption pass at a time per process: SQLite has a single writer, and +/// two concurrent votes must not both promote rivals of the same unit. +static PASS: Mutex<()> = Mutex::const_new(()); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Entered { + pub proposal_id: String, + pub unit_id: String, + pub code: String, + pub kind: &'static str, +} + +pub async fn enqueue_publication(state: &AppState, doc: &DocRow, reason: &str) -> AppResult<()> { + if doc.publish_path.is_none() { + return Ok(()); + } + let now = util::now(); + sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)") + .bind(&doc.id) + .bind(reason) + .bind(&now) + .bind(&now) + .execute(&state.db) + .await?; + state.publish_wake.notify_one(); + Ok(()) +} + +/// Seeds a minima that later reach their own threshold are marked confirmed. +async fn confirm_seeds(state: &AppState, st: &DocState) -> AppResult<()> { + for unit in &st.units { + let Some(fid) = &unit.in_force_id else { continue }; + let Some(p) = st.proposals.iter().find(|p| &p.id == fid) else { continue }; + if p.entered_at.is_some() || p.confirmed_at.is_some() { + continue; + } + if st.evaluation(unit, p).is_some_and(|e| e.reached) { + sqlx::query("UPDATE proposals SET confirmed_at = ? WHERE id = ? AND confirmed_at IS NULL") + .bind(util::now()) + .bind(&p.id) + .execute(&state.db) + .await?; + } + } + Ok(()) +} + +pub async fn run(state: &AppState, doc_id: &str) -> AppResult> { + let _pass = PASS.lock().await; + let st = docs::load_state(state, doc_id).await?; + confirm_seeds(state, &st).await?; + let units = st.unit_states(); + let winners = compose::adoptions(&units, &st.proposal_states(), st.electorate, st.doc.vote_floor) + .map_err(|_| crate::error::AppError::Invalid("electorate"))?; + let mut entered = Vec::new(); + for id in winners { + let Some(p) = st.proposals.iter().find(|p| p.id == id) else { continue }; + let Some(unit) = st.units.iter().find(|u| u.id == p.unit_id) else { continue }; + let us = units.iter().find(|u| u.id == unit.id); + let tally = st.tally(&p.id); + let eval = st.evaluation(unit, p); + let params = us.map(|u| compose::params_for(u, p.kind(), st.doc.vote_floor)); + let now = util::now(); + let mut tx = state.db.begin().await?; + let kind = match p.kind() { + ProposalKind::Text | ProposalKind::Remove => { + // The displaced version goes back among the proposals, with its votes. + if let Some(old) = &unit.in_force_id { + sqlx::query("UPDATE proposals SET status = 'open', left_at = ?, updated_at = ? WHERE id = ?") + .bind(&now) + .bind(&now) + .bind(old) + .execute(&mut *tx) + .await?; + } + if p.kind() == ProposalKind::Text { + sqlx::query("UPDATE proposals SET status = 'in_force', entered_at = ?, left_at = NULL, updated_at = ? WHERE id = ?") + .bind(&now) + .bind(&now) + .bind(&p.id) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?") + .bind(&p.id) + .bind(&unit.id) + .execute(&mut *tx) + .await?; + "text" + } else { + sqlx::query("UPDATE proposals SET status = 'applied', entered_at = ?, updated_at = ? WHERE id = ?") + .bind(&now) + .bind(&now) + .bind(&p.id) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE units SET in_force_id = NULL WHERE id = ?") + .bind(&unit.id) + .execute(&mut *tx) + .await?; + "remove" + } + } + ProposalKind::Inertia(target) => { + sqlx::query("UPDATE proposals SET status = 'applied', entered_at = ?, updated_at = ? WHERE id = ?") + .bind(&now) + .bind(&now) + .bind(&p.id) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE units SET inertia = ? WHERE id = ?") + .bind(target.as_str()) + .bind(&unit.id) + .execute(&mut *tx) + .await?; + "inertia" + } + }; + sqlx::query( + "INSERT INTO adoptions (proposal_id, unit_id, doc_id, kind, votes_for, votes_against, electorate, required, params, at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&p.id) + .bind(&unit.id) + .bind(&st.doc.id) + .bind(kind) + .bind(i64::from(tally.for_votes)) + .bind(i64::from(tally.against)) + .bind(i64::from(st.electorate)) + .bind(eval.map_or(0, |e| i64::from(e.required))) + .bind(serde_json::to_string(¶ms).unwrap_or_default()) + .bind(&now) + .execute(&mut *tx) + .await?; + tx.commit().await?; + tracing::info!(doc = %st.doc.id, unit = %unit.code, proposal = %p.id, kind, "entered into force"); + entered.push(Entered { proposal_id: p.id.clone(), unit_id: unit.id.clone(), code: unit.code.clone(), kind }); + } + if !entered.is_empty() { + let codes: Vec<&str> = entered.iter().map(|e| e.code.as_str()).collect(); + enqueue_publication(state, &st.doc, &format!("entrée en vigueur : {}", codes.join(", "))).await?; + } + Ok(entered) +} + +/// The electorate changes (the web of trust grows or shrinks): every few +/// minutes, every document is passed through the engine again. +pub async fn sweeper(state: AppState) { + let mut tick = tokio::time::interval(Duration::from_secs(300)); + loop { + tick.tick().await; + let ids: Vec = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await { + Ok(ids) => ids, + Err(e) => { + tracing::warn!(error = %e, "sweeper"); + continue; + } + }; + for id in ids { + if let Err(e) = run(&state, &id).await { + tracing::warn!(doc = %id, error = %e, "sweeper pass"); + } + } + } +} + +/// Unit states, re-exported for tests. +pub fn unit_states(st: &DocState) -> Vec { + st.unit_states() +} diff --git a/crates/ld-server/src/auth.rs b/crates/ld-server/src/auth.rs new file mode 100644 index 0000000..8d8aadd --- /dev/null +++ b/crates/ld-server/src/auth.rs @@ -0,0 +1,516 @@ +//! Sign-in: the Ğ1 access of the Monnaie libre forum, invitation links, and a +//! local development login. +//! +//! **Ğ1.** libreDecision is an OAuth2 client of `connect.monnaie-libre.fr` +//! (sso-connect): the person signs a one-time challenge in Ğecko, the service +//! reads the chain and answers with the on-chain identity. The account is +//! keyed on `idty:`, which survives a change of wallet key. Members of +//! the web of trust only — sso-connect refuses the others before we see them. +//! +//! **Invitation.** A single-use link created in trustinWallet by an access +//! administrator; it signs a person in as an invited identity (tests, or a +//! person without Ğ1 sign-in yet), with the groups the link grants. +//! +//! **Dev.** `LD_DEV_LOGIN=1` only, never in production. + +use crate::AppState; +use crate::error::{AppError, AppResult}; +use crate::util; +use axum::Json; +use axum::extract::{FromRequestParts, Query, State}; +use axum::http::header::{COOKIE, LOCATION, SET_COOKIE}; +use axum::http::request::Parts; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use trustin_access::{Identity, IdentityKind}; + +pub const SESSION_COOKIE: &str = "ld_session"; +const OAUTH_COOKIE: &str = "ld_oauth"; +const SESSION_DAYS: i64 = 30; +const INVITATION_DAYS: i64 = 14; + +fn cookie_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get_all(COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|v| v.split(';')) + .filter_map(|pair| pair.trim().split_once('=')) + .find(|(k, _)| *k == name) + .map(|(_, v)| v.to_owned()) +} + +fn cookie(state: &AppState, name: &str, value: &str, path: &str, max_age: i64) -> HeaderValue { + let secure = if state.cfg.secure_cookies() { "; Secure" } else { "" }; + let raw = format!("{name}={value}; Path={path}; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}"); + HeaderValue::from_str(&raw).unwrap_or_else(|_| HeaderValue::from_static("")) +} + +#[derive(Serialize, sqlx::FromRow)] +#[serde(rename_all = "camelCase")] +pub struct IdentityRow { + pub id: String, + pub kind: String, + pub display_name: String, + pub address: Option, + pub g1_member: bool, + pub admin: bool, +} + +impl IdentityRow { + pub fn to_identity(&self) -> Identity { + Identity { + id: self.id.clone(), + kind: IdentityKind::parse(&self.kind).unwrap_or(IdentityKind::Invite), + display_name: self.display_name.clone(), + g1_member: self.g1_member, + admin: self.admin, + } + } +} + +pub const IDENTITY_COLUMNS: &str = "id, kind, display_name, address, g1_member, admin"; + +/// The signed-in person, if any. +pub struct Viewer(pub Option); + +/// The signed-in person; 401 otherwise. +pub struct User(pub IdentityRow); + +impl FromRequestParts for Viewer { + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let Some(token) = cookie_value(&parts.headers, SESSION_COOKIE) else { + return Ok(Self(None)); + }; + let row = sqlx::query_as::<_, IdentityRow>(&format!( + "SELECT i.{} FROM sessions s JOIN identities i ON i.id = s.identity_id + WHERE s.token_hash = ? AND s.expires_at > ?", + IDENTITY_COLUMNS.replace(", ", ", i.") + )) + .bind(util::sha256_hex(&token)) + .bind(util::now()) + .fetch_optional(&state.db) + .await?; + Ok(Self(row)) + } +} + +impl FromRequestParts for User { + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + match Viewer::from_request_parts(parts, state).await?.0 { + Some(row) => Ok(Self(row)), + None => Err(AppError::Unauthenticated), + } + } +} + +async fn open_session(state: &AppState, identity_id: &str) -> AppResult { + let token = util::token(); + sqlx::query("INSERT INTO sessions (token_hash, identity_id, created_at, expires_at) VALUES (?, ?, ?, ?)") + .bind(util::sha256_hex(&token)) + .bind(identity_id) + .bind(util::now()) + .bind(util::in_days(SESSION_DAYS)) + .execute(&state.db) + .await?; + sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = ?") + .bind(util::now()) + .bind(identity_id) + .execute(&state.db) + .await?; + Ok(cookie(state, SESSION_COOKIE, &token, "/", SESSION_DAYS * 86_400)) +} + +pub async fn load_identity(state: &AppState, id: &str) -> AppResult { + sqlx::query_as::<_, IdentityRow>(&format!("SELECT {IDENTITY_COLUMNS} FROM identities WHERE id = ?")) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound) +} + +async fn grant_groups(state: &AppState, identity_id: &str, groups: &[String], by: Option<&str>) -> AppResult<()> { + for slug in groups { + sqlx::query( + "INSERT OR IGNORE INTO memberships (group_slug, identity_id, granted_by, granted_at) + SELECT slug, ?, ?, ? FROM groups WHERE slug = ?", + ) + .bind(identity_id) + .bind(by) + .bind(util::now()) + .bind(slug) + .execute(&state.db) + .await?; + } + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AuthConfig { + g1: bool, + dev: bool, +} + +pub async fn config(State(state): State) -> Json { + Json(json!(AuthConfig { g1: state.cfg.connect.is_some(), dev: state.cfg.dev_login })) +} + +pub async fn me(Viewer(viewer): Viewer) -> Json { + Json(json!({ "me": viewer })) +} + +/// Only local paths, never `//host` nor a scheme. +fn safe_return(raw: Option<&str>) -> String { + match raw { + Some(p) if p.starts_with('/') && !p.starts_with("//") && !p.contains('\\') && p.len() < 512 => p.to_owned(), + _ => "/groupes".to_owned(), + } +} + +#[derive(Deserialize)] +pub struct StartQuery { + #[serde(rename = "return")] + return_to: Option, +} + +fn redirect(to: &str, cookies: Vec) -> Response { + let mut res = StatusCode::FOUND.into_response(); + if let Ok(v) = HeaderValue::from_str(to) { + res.headers_mut().insert(LOCATION, v); + } + for c in cookies { + res.headers_mut().append(SET_COOKIE, c); + } + res +} + +/// Step 1: send the browser to the approval page of connect.monnaie-libre.fr. +pub async fn g1_start(State(state): State, Query(q): Query) -> AppResult { + let connect = state.cfg.connect.as_ref().ok_or(AppError::Unavailable("g1_not_configured"))?; + let csrf = util::token(); + let back = safe_return(q.return_to.as_deref()); + let mut url = url::Url::parse(&format!("{}/oauth/authorize", connect.base_url)).map_err(anyhow::Error::from)?; + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", &connect.client_id) + .append_pair("redirect_uri", &state.cfg.connect_callback()) + .append_pair("state", &csrf); + let stash = format!("{csrf}.{}", url::form_urlencoded::byte_serialize(back.as_bytes()).collect::()); + Ok(redirect(url.as_str(), vec![cookie(&state, OAUTH_COOKIE, &stash, "/api/auth/g1", 600)])) +} + +#[derive(Deserialize)] +pub struct CallbackQuery { + code: Option, + state: Option, + error: Option, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, +} + +/// What sso-connect's `/oauth/userinfo` answers — and nothing else. +#[derive(Deserialize)] +pub struct UserInfo { + pub id: String, + pub username: String, + pub address: Option, + #[serde(default)] + pub member: bool, +} + +/// Step 3: check the state, exchange the code, read the identity, open a session. +pub async fn g1_callback( + State(state): State, + headers: HeaderMap, + Query(q): Query, +) -> Response { + let clear = cookie(&state, OAUTH_COOKIE, "", "/api/auth/g1", 0); + let stash = cookie_value(&headers, OAUTH_COOKIE).unwrap_or_default(); + let (expected, back) = stash.split_once('.').unwrap_or(("", "")); + let back = + safe_return(url::form_urlencoded::parse(format!("p={back}").as_bytes()).next().map(|(_, v)| v).as_deref()); + let fail = |code: &str| redirect(&format!("/connexion?erreur={code}"), vec![clear.clone()]); + if let Some(err) = q.error.as_deref() { + tracing::warn!(error = err, "connect returned an error"); + return fail("refus"); + } + let (Some(code), Some(got)) = (q.code, q.state) else { return fail("incomplet") }; + if expected.is_empty() || expected != got { + return fail("etat"); + } + match exchange(&state, &code).await { + Ok(info) => match sign_in_g1(&state, info).await { + Ok(session) => redirect(&back, vec![clear, session]), + Err(e) => { + tracing::error!(error = %e, "g1 sign-in"); + fail("interne") + } + }, + Err(e) => { + tracing::warn!(error = %e, "code exchange"); + fail("echange") + } + } +} + +async fn exchange(state: &AppState, code: &str) -> anyhow::Result { + let connect = state.cfg.connect.as_ref().ok_or_else(|| anyhow::anyhow!("not configured"))?; + let token: TokenResponse = state + .http + .post(format!("{}/oauth/token", connect.base_url)) + .basic_auth(&connect.client_id, Some(&connect.client_secret)) + .form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", &state.cfg.connect_callback())]) + .send() + .await? + .error_for_status()? + .json() + .await?; + let info: UserInfo = state + .http + .get(format!("{}/oauth/userinfo", connect.base_url)) + .bearer_auth(token.access_token) + .send() + .await? + .error_for_status()? + .json() + .await?; + anyhow::ensure!(info.id.starts_with("idty:"), "unexpected identity key"); + Ok(info) +} + +pub async fn sign_in_g1(state: &AppState, info: UserInfo) -> AppResult { + let admin = state.cfg.admin_g1_ids.iter().any(|a| a == &info.id); + sqlx::query( + "INSERT INTO identities (id, kind, display_name, address, g1_member, admin, created_at) + VALUES (?, 'g1', ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, address = excluded.address, + g1_member = excluded.g1_member, admin = MAX(identities.admin, excluded.admin)", + ) + .bind(&info.id) + .bind(&info.username) + .bind(&info.address) + .bind(info.member) + .bind(admin) + .bind(util::now()) + .execute(&state.db) + .await?; + open_session(state, &info.id).await +} + +#[derive(Deserialize)] +pub struct InvitationBody { + token: String, +} + +#[derive(sqlx::FromRow)] +struct InvitationRow { + token_hash: String, + label: String, + admin: bool, + groups: String, + created_by: Option, + identity_id: Option, +} + +pub async fn accept_invitation(State(state): State, Json(body): Json) -> AppResult { + let hash = util::sha256_hex(body.token.trim()); + let inv = sqlx::query_as::<_, InvitationRow>( + "SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations + WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?", + ) + .bind(&hash) + .bind(util::now()) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::Invalid("invitation_invalid"))?; + + let identity_id = match inv.identity_id.clone() { + Some(existing) => existing, + None => { + let id = format!("inv:{}", util::id()); + sqlx::query( + "INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES (?, 'invite', ?, 0, ?, ?)", + ) + .bind(&id) + .bind(&inv.label) + .bind(inv.admin) + .bind(util::now()) + .execute(&state.db) + .await?; + id + } + }; + if inv.admin { + sqlx::query("UPDATE identities SET admin = 1 WHERE id = ?").bind(&identity_id).execute(&state.db).await?; + } + let groups: Vec = serde_json::from_str(&inv.groups).unwrap_or_default(); + grant_groups(&state, &identity_id, &groups, inv.created_by.as_deref()).await?; + let used = + sqlx::query("UPDATE invitations SET used_at = ?, identity_id = ? WHERE token_hash = ? AND used_at IS NULL") + .bind(util::now()) + .bind(&identity_id) + .bind(&inv.token_hash) + .execute(&state.db) + .await?; + if used.rows_affected() != 1 { + return Err(AppError::Invalid("invitation_invalid")); + } + let session = open_session(&state, &identity_id).await?; + let me = load_identity(&state, &identity_id).await?; + let mut res = Json(json!({ "me": me })).into_response(); + res.headers_mut().append(SET_COOKIE, session); + Ok(res) +} + +/// Create an invitation; returns the raw token, shown once. +pub async fn create_invitation( + state: &AppState, + label: &str, + admin: bool, + groups: &[String], + created_by: Option<&str>, + identity_id: Option<&str>, +) -> AppResult { + let token = util::token(); + sqlx::query( + "INSERT INTO invitations (token_hash, label, admin, groups, created_by, created_at, expires_at, identity_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(util::sha256_hex(&token)) + .bind(label) + .bind(admin) + .bind(serde_json::to_string(groups).unwrap_or_else(|_| "[]".into())) + .bind(created_by) + .bind(util::now()) + .bind(util::in_days(INVITATION_DAYS)) + .bind(identity_id) + .execute(&state.db) + .await?; + Ok(token) +} + +pub fn invitation_link(state: &AppState, token: &str) -> String { + format!("{}/invitation#{token}", state.cfg.public_url) +} + +#[derive(Deserialize)] +pub struct DevBody { + name: String, + #[serde(default)] + admin: bool, +} + +/// Local development: any name, member of every group. Refused unless LD_DEV_LOGIN=1. +pub async fn dev_login(State(state): State, Json(body): Json) -> AppResult { + if !state.cfg.dev_login { + return Err(AppError::NotFound); + } + let name = body.name.trim(); + if name.is_empty() || name.len() > 40 { + return Err(AppError::Invalid("name")); + } + let slug: String = name.to_lowercase().chars().map(|c| if c.is_alphanumeric() { c } else { '-' }).collect(); + let id = format!("dev:{slug}"); + sqlx::query( + "INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES (?, 'dev', ?, 0, ?, ?) + ON CONFLICT(id) DO UPDATE SET admin = excluded.admin", + ) + .bind(&id) + .bind(name) + .bind(body.admin) + .bind(util::now()) + .execute(&state.db) + .await?; + let all: Vec = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?; + grant_groups(&state, &id, &all, Some("dev")).await?; + let session = open_session(&state, &id).await?; + let me = load_identity(&state, &id).await?; + let mut res = Json(json!({ "me": me })).into_response(); + res.headers_mut().append(SET_COOKIE, session); + Ok(res) +} + +pub async fn logout(State(state): State, headers: HeaderMap) -> AppResult { + if let Some(token) = cookie_value(&headers, SESSION_COOKIE) { + sqlx::query("DELETE FROM sessions WHERE token_hash = ?") + .bind(util::sha256_hex(&token)) + .execute(&state.db) + .await?; + } + let mut res = Json(json!({ "me": null })).into_response(); + res.headers_mut().append(SET_COOKIE, cookie(&state, SESSION_COOKIE, "", "/", 0)); + Ok(res) +} + +/// First start with LD_BOOTSTRAP_ADMIN=1 and no administrator yet: write a +/// single-use administrator invitation to `/bootstrap-admin.txt` (0600). +/// Nothing is printed to the logs. +pub async fn bootstrap(state: &AppState) -> anyhow::Result<()> { + if !state.cfg.bootstrap_admin { + return Ok(()); + } + let admins: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE admin = 1").fetch_one(&state.db).await?; + let pending: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM invitations WHERE admin = 1 AND used_at IS NULL AND expires_at > ?") + .bind(util::now()) + .fetch_one(&state.db) + .await?; + if admins > 0 || pending > 0 { + return Ok(()); + } + let groups: Vec = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?; + let token = create_invitation(state, "Yvv", true, &groups, None, None) + .await + .map_err(|e| anyhow::anyhow!("bootstrap invitation: {e}"))?; + let path = state.cfg.data_dir.join("bootstrap-admin.txt"); + write_private(&path, &format!("{}\n", invitation_link(state, &token)))?; + tracing::info!(path = %path.display(), "administrator invitation written"); + Ok(()) +} + +fn write_private(path: &std::path::Path, content: &str) -> std::io::Result<()> { + use std::io::Write; + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(path)?; + f.write_all(content.as_bytes()) + } + #[cfg(not(unix))] + { + std::fs::write(path, content) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn return_paths_stay_local() { + assert_eq!(safe_return(Some("/g/monnaie-libre")), "/g/monnaie-libre"); + assert_eq!(safe_return(Some("//evil.example")), "/groupes"); + assert_eq!(safe_return(Some("https://evil.example")), "/groupes"); + assert_eq!(safe_return(Some("/\\evil")), "/groupes"); + assert_eq!(safe_return(None), "/groupes"); + } + + #[test] + fn cookie_parsing() { + let mut h = HeaderMap::new(); + h.insert(COOKIE, HeaderValue::from_static("a=1; ld_session=abc; b=2")); + assert_eq!(cookie_value(&h, SESSION_COOKIE).as_deref(), Some("abc")); + assert_eq!(cookie_value(&h, "zz"), None); + } +} diff --git a/crates/ld-server/src/config.rs b/crates/ld-server/src/config.rs new file mode 100644 index 0000000..df831bf --- /dev/null +++ b/crates/ld-server/src/config.rs @@ -0,0 +1,139 @@ +//! Configuration, from the environment only. Secrets never have a default. + +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct Connect { + /// Base URL of the sso-connect instance (no trailing slash). + pub base_url: String, + pub client_id: String, + pub client_secret: String, +} + +#[derive(Clone, Debug)] +pub struct Publish { + /// Remote of the texts repository, e.g. `git@git.duniter.org:Yvv/libredecision-textes.git`. + pub remote: String, + pub branch: String, + /// Private SSH key allowed to push there (deploy key). + pub key: PathBuf, + pub author_name: String, + pub author_email: String, +} + +#[derive(Clone, Debug)] +pub struct Config { + pub bind: String, + pub data_dir: PathBuf, + pub web_dir: PathBuf, + pub seed_dir: PathBuf, + /// Public origin, e.g. `https://decision.librodrome.org` — callbacks and cookies. + pub public_url: String, + pub connect: Option, + pub admin_g1_ids: Vec, + pub dev_login: bool, + pub bootstrap_admin: bool, + pub publish: Option, + pub rpc_endpoints: Vec, + pub indexers: Vec, + pub wot_fallback: u32, + pub build: String, +} + +fn var(name: &str) -> Option { + std::env::var(name).ok().map(|v| v.trim().to_owned()).filter(|v| !v.is_empty()) +} + +fn flag(name: &str) -> bool { + var(name).is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on")) +} + +fn list(name: &str, default: &[&str]) -> Vec { + match var(name) { + Some(v) => v.split(',').map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()).collect(), + None => default.iter().map(|s| (*s).to_owned()).collect(), + } +} + +impl Config { + pub fn from_env() -> Self { + let data_dir = PathBuf::from(var("LD_DATA_DIR").unwrap_or_else(|| "data".into())); + let connect = match (var("LD_CONNECT_CLIENT_ID"), var("LD_CONNECT_CLIENT_SECRET")) { + (Some(client_id), Some(client_secret)) => Some(Connect { + base_url: var("LD_CONNECT_URL") + .unwrap_or_else(|| "https://connect.monnaie-libre.fr".into()) + .trim_end_matches('/') + .to_owned(), + client_id, + client_secret, + }), + _ => None, + }; + let publish = var("LD_PUBLISH_REMOTE").map(|remote| Publish { + remote, + branch: var("LD_PUBLISH_BRANCH").unwrap_or_else(|| "main".into()), + key: var("LD_PUBLISH_KEY").map(PathBuf::from).unwrap_or_else(|| data_dir.join("keys/publish_ed25519")), + author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()), + author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()), + }); + Self { + bind: var("LD_BIND").unwrap_or_else(|| "0.0.0.0:8002".into()), + web_dir: PathBuf::from(var("LD_WEB_DIR").unwrap_or_else(|| "web/build".into())), + seed_dir: PathBuf::from(var("LD_SEED_DIR").unwrap_or_else(|| "seeds".into())), + public_url: var("LD_PUBLIC_URL") + .unwrap_or_else(|| "http://127.0.0.1:3002".into()) + .trim_end_matches('/') + .to_owned(), + connect, + admin_g1_ids: list("LD_ADMIN_G1_IDS", &[]), + dev_login: flag("LD_DEV_LOGIN"), + bootstrap_admin: flag("LD_BOOTSTRAP_ADMIN"), + publish, + rpc_endpoints: list( + "LD_RPC_ENDPOINTS", + &[ + "https://rpc.duniter.org", + "https://g1.axiom-team.fr/ws/", + "https://g1.asycn.io/ws/", + "https://g1.coinduf.eu", + ], + ), + indexers: list( + "LD_INDEXERS", + &["https://indexer.duniter.org/v1/graphql", "https://g1-squid.axiom-team.fr/v1/graphql"], + ), + wot_fallback: var("LD_WOT_FALLBACK").and_then(|v| v.parse().ok()).unwrap_or(5745), + build: var("LD_BUILD").unwrap_or_else(|| "dev".into()), + data_dir, + } + } + + /// Cookies are `Secure` as soon as the public origin is https. + pub fn secure_cookies(&self) -> bool { + self.public_url.starts_with("https://") + } + + pub fn connect_callback(&self) -> String { + format!("{}/api/auth/g1/callback", self.public_url) + } + + /// For tests: an in-memory configuration with dev login. + pub fn for_tests() -> Self { + Self { + bind: "127.0.0.1:0".into(), + data_dir: std::env::temp_dir().join("ld-tests"), + web_dir: PathBuf::from("web/build"), + seed_dir: PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../seeds")), + public_url: "http://127.0.0.1:3002".into(), + connect: None, + admin_g1_ids: vec!["idty:8352".into()], + dev_login: true, + bootstrap_admin: false, + publish: None, + rpc_endpoints: vec![], + indexers: vec![], + wot_fallback: 5745, + build: "test".into(), + } + } +} diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs new file mode 100644 index 0000000..80e6d37 --- /dev/null +++ b/crates/ld-server/src/docs.rs @@ -0,0 +1,618 @@ +//! Reading: groups, tools, documents, previews. +//! +//! The state of a document is loaded once (`DocState`) and turned into the +//! engine's pure types; every number shown on screen comes from the engine. + +use crate::AppState; +use crate::auth::{IdentityRow, User}; +use crate::error::{AppError, AppResult}; +use crate::util::{self, LangMap}; +use axum::Json; +use axum::extract::{Path, Query, State}; +use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText}; +use ld_engine::threshold::{Evaluation, InertiaPreset, Tally, evaluate}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::{BTreeMap, HashMap, HashSet}; +use trustin_access::{Group, GroupPolicy}; + +#[derive(Clone, Debug, sqlx::FromRow)] +pub struct GroupRow { + pub slug: String, + pub name: String, + pub parent: Option, + pub policy: String, + pub hidden: bool, + pub position: i64, +} + +impl GroupRow { + pub fn to_group(&self) -> Group { + Group { + slug: self.slug.clone(), + name: self.name.clone(), + parent: self.parent.clone(), + policy: GroupPolicy::parse(&self.policy).unwrap_or(GroupPolicy::Managed), + hidden: self.hidden, + } + } +} + +pub async fn granted(state: &AppState, group: &str, identity: &str) -> AppResult { + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM memberships WHERE group_slug = ? AND identity_id = ?") + .bind(group) + .bind(identity) + .fetch_one(&state.db) + .await?; + Ok(n > 0) +} + +pub async fn load_group(state: &AppState, slug: &str) -> AppResult { + sqlx::query_as::<_, GroupRow>("SELECT slug, name, parent, policy, hidden, position FROM groups WHERE slug = ?") + .bind(slug) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound) +} + +/// 404 for a group one cannot see, 403 for one seen but not open. +pub async fn require_open(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<()> { + let id = user.to_identity(); + let g = group.to_group(); + let has = granted(state, &group.slug, &user.id).await?; + if !trustin_access::sees(&id, &g, has) { + return Err(AppError::NotFound); + } + if !trustin_access::opens(&id, &g, has) { + return Err(AppError::Forbidden); + } + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GroupView { + slug: String, + name: String, + parent: Option, + policy: String, + open: bool, +} + +pub async fn groups(State(state): State, User(user): User) -> AppResult> { + let rows = sqlx::query_as::<_, GroupRow>( + "SELECT slug, name, parent, policy, hidden, position FROM groups ORDER BY position", + ) + .fetch_all(&state.db) + .await?; + let id = user.to_identity(); + let mut out = Vec::new(); + for row in rows { + let has = granted(&state, &row.slug, &user.id).await?; + let g = row.to_group(); + if trustin_access::sees(&id, &g, has) { + out.push(GroupView { + open: trustin_access::opens(&id, &g, has), + slug: row.slug, + name: row.name, + parent: row.parent, + policy: row.policy, + }); + } + } + Ok(Json(json!({ "groups": out }))) +} + +#[derive(Serialize, sqlx::FromRow)] +#[serde(rename_all = "camelCase")] +struct DocListRow { + id: String, + slug: String, + #[sqlx(rename = "title")] + #[serde(skip)] + title_raw: String, + #[sqlx(rename = "summary")] + #[serde(skip)] + summary_raw: String, + in_force: i64, + open: i64, +} + +/// The tools of a group. Only "Docs de référence" is active for now. +pub const TOOLS: [(&str, bool); 4] = [("decisions", false), ("docs", true), ("mandats", false), ("protocoles", false)]; + +pub async fn group( + State(state): State, + User(user): User, + Path(slug): Path, +) -> AppResult> { + let row = load_group(&state, &slug).await?; + let id = user.to_identity(); + let has = granted(&state, &slug, &user.id).await?; + let g = row.to_group(); + if !trustin_access::sees(&id, &g, has) { + return Err(AppError::NotFound); + } + let open = trustin_access::opens(&id, &g, has); + let docs = if open { + sqlx::query_as::<_, DocListRow>( + "SELECT d.id, d.slug, d.title, d.summary, + (SELECT COUNT(*) FROM units u WHERE u.doc_id = d.id AND u.in_force_id IS NOT NULL) AS in_force, + (SELECT COUNT(*) FROM proposals p JOIN units u ON u.id = p.unit_id + WHERE u.doc_id = d.id AND p.status = 'open') AS open + FROM documents d WHERE d.group_slug = ? ORDER BY d.created_at", + ) + .bind(&slug) + .fetch_all(&state.db) + .await? + .into_iter() + .map(|d| { + json!({ + "id": d.id, "slug": d.slug, + "title": util::parse_lang(&d.title_raw), "summary": util::parse_lang(&d.summary_raw), + "inForce": d.in_force, "open": d.open, + }) + }) + .collect::>() + } else { + Vec::new() + }; + let tools: Vec<_> = TOOLS.iter().map(|(key, active)| json!({ "key": key, "active": active })).collect(); + Ok(Json(json!({ + "group": { "slug": row.slug, "name": row.name, "parent": row.parent, "policy": row.policy, "open": open }, + "tools": tools, + "docs": docs, + }))) +} + +// --------------------------------------------------------------------------- +// Document state +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, sqlx::FromRow)] +pub struct DocRow { + pub id: String, + pub group_slug: String, + pub slug: String, + pub title: String, + pub summary: String, + pub source_lang: String, + pub vote_floor: f64, + pub publish_path: Option, +} + +#[derive(Clone, Debug, sqlx::FromRow)] +pub struct UnitRow { + pub id: String, + pub code: String, + pub position: f64, + pub inertia: String, + pub in_force_id: Option, +} + +#[derive(Clone, Debug, sqlx::FromRow)] +pub struct ProposalRow { + pub id: String, + pub unit_id: String, + pub kind: String, + pub inertia_target: Option, + pub title: String, + pub body: String, + pub status: String, + pub origin: String, + pub label: Option, + pub author_id: Option, + pub author_name: Option, + pub revision: i64, + pub created_at: String, + pub updated_at: String, + pub submitted_at: Option, + pub entered_at: Option, + pub confirmed_at: Option, +} + +pub const PROPOSAL_SELECT: &str = + "SELECT p.id, p.unit_id, p.kind, p.inertia_target, p.title, p.body, p.status, p.origin, + p.label, p.author_id, i.display_name AS author_name, p.revision, p.created_at, p.updated_at, p.submitted_at, + p.entered_at, p.confirmed_at + FROM proposals p LEFT JOIN identities i ON i.id = p.author_id"; + +impl ProposalRow { + pub fn kind(&self) -> ProposalKind { + match self.kind.as_str() { + "remove" => ProposalKind::Remove, + "inertia" => ProposalKind::Inertia( + self.inertia_target.as_deref().and_then(InertiaPreset::parse).unwrap_or(InertiaPreset::Standard), + ), + _ => ProposalKind::Text, + } + } +} + +pub fn inertia_of(unit: &UnitRow) -> InertiaPreset { + InertiaPreset::parse(&unit.inertia).unwrap_or(InertiaPreset::Standard) +} + +pub struct DocState { + pub doc: DocRow, + pub group: GroupRow, + pub units: Vec, + pub proposals: Vec, + pub tallies: HashMap, + pub blocked: HashSet, + pub signal_counts: HashMap, + pub electorate: u32, + pub electorate_source: &'static str, +} + +pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult { + sqlx::query_as::<_, DocRow>( + "SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path FROM documents WHERE id = ?", + ) + .bind(doc_id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound) +} + +/// W: the Ğ1 web of trust for Monnaie libre, the members of the group otherwise. +pub async fn electorate(state: &AppState, group: &GroupRow) -> AppResult<(u32, &'static str)> { + if group.policy == GroupPolicy::G1Members.as_str() { + let cached: Option = + sqlx::query_scalar("SELECT value FROM settings WHERE key = 'wot_size'").fetch_optional(&state.db).await?; + let w = cached.and_then(|v| v.parse().ok()).unwrap_or(state.cfg.wot_fallback); + return Ok((w.max(1), "wot")); + } + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM memberships WHERE group_slug = ?") + .bind(&group.slug) + .fetch_one(&state.db) + .await?; + Ok((u32::try_from(n).unwrap_or(u32::MAX).max(1), "group")) +} + +pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { + let doc = load_doc(state, doc_id).await?; + let group = load_group(state, &doc.group_slug).await?; + let units = sqlx::query_as::<_, UnitRow>( + "SELECT id, code, position, inertia, in_force_id FROM units WHERE doc_id = ? ORDER BY position, id", + ) + .bind(doc_id) + .fetch_all(&state.db) + .await?; + let proposals = sqlx::query_as::<_, ProposalRow>(&format!( + "{PROPOSAL_SELECT} WHERE p.unit_id IN (SELECT id FROM units WHERE doc_id = ?) + AND p.status IN ('open', 'in_force', 'draft') ORDER BY p.submitted_at, p.created_at, p.id" + )) + .bind(doc_id) + .fetch_all(&state.db) + .await?; + + #[derive(sqlx::FromRow)] + struct TallyRow { + proposal_id: String, + choice: String, + n: i64, + } + let rows = sqlx::query_as::<_, TallyRow>( + "SELECT v.proposal_id, v.choice, COUNT(*) AS n FROM votes v JOIN proposals p ON p.id = v.proposal_id + JOIN units u ON u.id = p.unit_id + WHERE u.doc_id = ? AND v.revision = p.revision GROUP BY v.proposal_id, v.choice", + ) + .bind(doc_id) + .fetch_all(&state.db) + .await?; + let mut tallies: HashMap = HashMap::new(); + for r in rows { + let t = tallies.entry(r.proposal_id).or_default(); + let n = u32::try_from(r.n).unwrap_or(0); + match r.choice.as_str() { + "for" => t.for_votes = n, + "against" => t.against = n, + _ => {} + } + } + + #[derive(sqlx::FromRow)] + struct SignalRow { + proposal_id: String, + target_proposal_id: String, + } + let signals = sqlx::query_as::<_, SignalRow>( + "SELECT s.proposal_id, s.target_proposal_id FROM signals s JOIN proposals p ON p.id = s.proposal_id + JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND s.lifted_at IS NULL", + ) + .bind(doc_id) + .fetch_all(&state.db) + .await?; + let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect(); + let mut blocked = HashSet::new(); + let mut signal_counts: HashMap = HashMap::new(); + for s in &signals { + *signal_counts.entry(s.proposal_id.clone()).or_default() += 1; + *signal_counts.entry(s.target_proposal_id.clone()).or_default() += 1; + // Two texts that contradict each other never stand in force together. + if in_force.contains(s.target_proposal_id.as_str()) { + blocked.insert(s.proposal_id.clone()); + } + if in_force.contains(s.proposal_id.as_str()) { + blocked.insert(s.target_proposal_id.clone()); + } + } + let (electorate, electorate_source) = electorate(state, &group).await?; + Ok(DocState { doc, group, units, proposals, tallies, blocked, signal_counts, electorate, electorate_source }) +} + +impl DocState { + pub fn tally(&self, id: &str) -> Tally { + self.tallies.get(id).copied().unwrap_or_default() + } + + pub fn unit_states(&self) -> Vec { + let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect(); + self.units + .iter() + .map(|u| UnitState { + id: u.id.clone(), + position: u.position, + inertia: inertia_of(u), + in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| VersionText { + id: p.id.clone(), + title: p.title.clone(), + body: p.body.clone(), + tally: self.tally(&p.id), + }), + }) + .collect() + } + + /// Open proposals only — the in-force versions compete as incumbents. + pub fn proposal_states(&self) -> Vec { + self.proposals + .iter() + .filter(|p| p.status == "open") + .map(|p| ProposalState { + id: p.id.clone(), + unit_id: p.unit_id.clone(), + kind: p.kind(), + title: p.title.clone(), + body: p.body.clone(), + tally: self.tally(&p.id), + submitted_at: p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()), + blocked: self.blocked.contains(&p.id), + }) + .collect() + } + + pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option { + let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None }; + evaluate(self.tally(&p.id), self.electorate, &compose::params_for(&us, p.kind(), self.doc.vote_floor)).ok() + } +} + +// --------------------------------------------------------------------------- +// Document view +// --------------------------------------------------------------------------- + +#[derive(sqlx::FromRow)] +struct TranslationRow { + proposal_id: String, + lang: String, + title: String, + body: String, + source_hash: String, + origin: String, +} + +pub async fn translations_for( + state: &AppState, + ids: &[&ProposalRow], +) -> AppResult>> { + let mut out: HashMap> = HashMap::new(); + if ids.is_empty() { + return Ok(out); + } + let marks = vec!["?"; ids.len()].join(","); + let sql = format!( + "SELECT proposal_id, lang, title, body, source_hash, origin FROM translations WHERE proposal_id IN ({marks})" + ); + let mut q = sqlx::query_as::<_, TranslationRow>(&sql); + for p in ids { + q = q.bind(&p.id); + } + let rows = q.fetch_all(&state.db).await?; + let current: HashMap<&str, String> = + ids.iter().map(|p| (p.id.as_str(), util::source_hash(&p.title, &p.body))).collect(); + for r in rows { + let stale = current.get(r.proposal_id.as_str()).is_none_or(|h| *h != r.source_hash); + out.entry(r.proposal_id) + .or_default() + .insert(r.lang, json!({ "title": r.title, "body": r.body, "stale": stale, "origin": r.origin })); + } + Ok(out) +} + +#[derive(sqlx::FromRow)] +struct MyVote { + proposal_id: String, + choice: String, + revision: i64, +} + +fn proposal_json( + st: &DocState, + unit: &UnitRow, + p: &ProposalRow, + mine: Option<&MyVote>, + translations: Option<&BTreeMap>, + me: &str, +) -> serde_json::Value { + let eval = st.evaluation(unit, p); + let (kind, target) = match p.kind() { + ProposalKind::Text => ("text", None), + ProposalKind::Remove => ("remove", None), + ProposalKind::Inertia(t) => ("inertia", Some(t.as_str())), + }; + let voted_at = match p.kind() { + ProposalKind::Inertia(_) => inertia_of(unit).raised(), + _ => inertia_of(unit), + }; + json!({ + "id": p.id, "unitId": p.unit_id, "kind": kind, "inertiaTarget": target, + "title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label, + "author": p.author_id.as_ref().map(|id| json!({ "id": id, "name": p.author_name })), + "mine": p.author_id.as_deref() == Some(me), + "revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at, + "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at, + "seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", + "tally": st.tally(&p.id), + "evaluation": eval, + "votedAtInertia": voted_at.as_str(), + "blocked": st.blocked.contains(&p.id), + "signals": st.signal_counts.get(&p.id).copied().unwrap_or(0), + "myVote": mine.map(|v| v.choice.as_str()), + "myVoteStale": mine.is_some_and(|v| v.revision != p.revision), + "translations": translations.cloned().unwrap_or_default(), + }) +} + +#[derive(Deserialize)] +pub struct DocQuery {} + +pub async fn document( + State(state): State, + User(user): User, + Path(id): Path, +) -> AppResult> { + let st = load_state(&state, &id).await?; + require_open(&state, &user, &st.group).await?; + let visible: Vec<&ProposalRow> = + st.proposals.iter().filter(|p| p.status != "draft" || p.author_id.as_deref() == Some(&user.id)).collect(); + let translations = translations_for(&state, &visible).await?; + let mine: HashMap = sqlx::query_as::<_, MyVote>( + "SELECT v.proposal_id, v.choice, v.revision FROM votes v JOIN proposals p ON p.id = v.proposal_id + JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND v.identity_id = ?", + ) + .bind(&id) + .bind(&user.id) + .fetch_all(&state.db) + .await? + .into_iter() + .map(|v| (v.proposal_id.clone(), v)) + .collect(); + + let units: Vec<_> = st + .units + .iter() + .map(|u| { + let of_unit: Vec<_> = visible + .iter() + .filter(|p| p.unit_id == u.id) + .map(|p| proposal_json(&st, u, p, mine.get(&p.id), translations.get(&p.id), &user.id)) + .collect(); + let in_force = u + .in_force_id + .as_ref() + .and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned()); + let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect(); + json!({ + "id": u.id, "code": u.code, "position": u.position, "inertia": u.inertia, + "inForce": in_force, "proposals": proposals, + }) + }) + .collect(); + + let publication = publication_status(&state, &id).await?; + Ok(Json(json!({ + "doc": { + "id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name, + "title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary), + "sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor, + "electorate": st.electorate, "electorateSource": st.electorate_source, + "formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE }, + "publication": publication, + }, + "units": units, + "inertia": InertiaPreset::ALL.iter().map(|p| json!({ + "key": p.as_str(), "majorityPct": p.majority_pct(), "gradient": p.gradient(), + })).collect::>(), + }))) +} + +pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult { + #[derive(sqlx::FromRow)] + struct Row { + created_at: String, + done_at: Option, + commit_sha: Option, + last_error: Option, + attempts: i64, + } + let last = sqlx::query_as::<_, Row>( + "SELECT created_at, done_at, commit_sha, last_error, attempts FROM outbox WHERE doc_id = ? ORDER BY id DESC LIMIT 1", + ) + .bind(doc_id) + .fetch_optional(&state.db) + .await?; + Ok(match last { + None => json!({ "state": "none", "configured": state.cfg.publish.is_some() }), + Some(r) => json!({ + "state": if r.done_at.is_some() { "done" } else { "pending" }, + "configured": state.cfg.publish.is_some(), + "since": r.created_at, "doneAt": r.done_at, "commit": r.commit_sha, + "attempts": r.attempts, "error": r.last_error.map(|_| "retrying"), + }), + }) +} + +#[derive(Deserialize)] +pub struct PreviewQuery { + mode: Option, +} + +pub async fn preview( + State(state): State, + User(user): User, + Path(id): Path, + Query(q): Query, +) -> AppResult> { + let st = load_state(&state, &id).await?; + require_open(&state, &user, &st.group).await?; + let mode = match q.mode.as_deref() { + Some("all") => PreviewMode::All, + Some("wellrated") => PreviewMode::WellRated, + _ => PreviewMode::InForce, + }; + let units = compose::preview(&st.unit_states(), &st.proposal_states(), st.electorate, st.doc.vote_floor, mode) + .map_err(|_| AppError::Invalid("electorate"))?; + // Translations of the versions shown. + let shown: HashSet = units + .iter() + .map(|u| { + u.proposal_id.clone().unwrap_or_else(|| { + st.units.iter().find(|x| x.id == u.unit_id).and_then(|x| x.in_force_id.clone()).unwrap_or_default() + }) + }) + .collect(); + let rows: Vec<&ProposalRow> = st.proposals.iter().filter(|p| shown.contains(&p.id)).collect(); + let tr = translations_for(&state, &rows).await?; + let out: Vec<_> = units + .into_iter() + .map(|u| { + let vid = u + .proposal_id + .clone() + .or_else(|| st.units.iter().find(|x| x.id == u.unit_id).and_then(|x| x.in_force_id.clone())); + let translations = vid.as_ref().and_then(|v| tr.get(v)).cloned().unwrap_or_default(); + json!({ + "unitId": u.unit_id, "title": u.title, "body": u.body, "mark": u.mark, + "proposalId": u.proposal_id, "versionId": vid, "translations": translations, + }) + }) + .collect(); + Ok(Json(json!({ "mode": q.mode.unwrap_or_else(|| "inforce".into()), "units": out }))) +} + +/// Unused for now; keeps the language list close to the documents. +pub fn source_langs() -> LangMap { + util::LANGS.iter().map(|l| ((*l).to_owned(), String::new())).collect() +} diff --git a/crates/ld-server/src/error.rs b/crates/ld-server/src/error.rs new file mode 100644 index 0000000..72ad508 --- /dev/null +++ b/crates/ld-server/src/error.rs @@ -0,0 +1,50 @@ +//! API errors: a stable machine code the interface translates, never a stack. + +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("unauthenticated")] + Unauthenticated, + #[error("forbidden")] + Forbidden, + #[error("not found")] + NotFound, + #[error("invalid: {0}")] + Invalid(&'static str), + #[error("conflict: {0}")] + Conflict(&'static str), + #[error("unavailable: {0}")] + Unavailable(&'static str), + #[error(transparent)] + Db(#[from] sqlx::Error), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +pub type AppResult = Result; + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, code) = match &self { + Self::Unauthenticated => (StatusCode::UNAUTHORIZED, "unauthenticated"), + Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"), + Self::NotFound => (StatusCode::NOT_FOUND, "not_found"), + Self::Invalid(code) => (StatusCode::BAD_REQUEST, *code), + Self::Conflict(code) => (StatusCode::CONFLICT, *code), + Self::Unavailable(code) => (StatusCode::SERVICE_UNAVAILABLE, *code), + Self::Db(e) => { + tracing::error!(error = %e, "database"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal") + } + Self::Other(e) => { + tracing::error!(error = %e, "internal"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal") + } + }; + (status, Json(json!({ "error": code }))).into_response() + } +} diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs new file mode 100644 index 0000000..1cb9b39 --- /dev/null +++ b/crates/ld-server/src/lib.rs @@ -0,0 +1,182 @@ +//! libreDecision server. +//! +//! One binary serves the JSON API under `/api`, the compiled Svelte interface, +//! and runs two background workers: the electorate refresher (size of the Ğ1 +//! web of trust) and the publisher (adopted texts pushed to the git repository +//! that client applications read). + +pub mod access; +pub mod adoption; +pub mod auth; +pub mod config; +pub mod docs; +pub mod error; +pub mod proposals; +pub mod publish; +pub mod seed; +pub mod util; +pub mod web; +pub mod wot; + +use axum::Router; +use axum::extract::{Request, State}; +use axum::http::{Method, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post, put}; +use sqlx::SqlitePool; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Notify; + +pub use config::Config; + +#[derive(Clone)] +pub struct AppState { + pub db: SqlitePool, + pub cfg: Arc, + pub http: reqwest::Client, + /// Wakes the publisher as soon as an adoption is recorded. + pub publish_wake: Arc, +} + +impl AppState { + pub async fn new(cfg: Config, db: SqlitePool) -> anyhow::Result { + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION"))) + .build()?; + Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()) }) + } +} + +/// Open (or create) the database and apply migrations. +pub async fn open_db(url: &str) -> anyhow::Result { + let options = SqliteConnectOptions::from_str(url)? + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .foreign_keys(true) + .busy_timeout(Duration::from_secs(5)); + let max = if url.contains(":memory:") { 1 } else { 8 }; + let pool = SqlitePoolOptions::new().max_connections(max).connect_with(options).await?; + sqlx::migrate!("./migrations").run(&pool).await?; + Ok(pool) +} + +/// Mutating API calls must carry this header: a cross-site form cannot set it, +/// and no CORS is ever granted, so it closes CSRF together with SameSite=Lax. +pub const CSRF_HEADER: &str = "x-requested-with"; +pub const CSRF_VALUE: &str = "libredecision"; + +async fn csrf_guard(req: Request, next: Next) -> Response { + let safe = matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS); + let has_header = req.headers().get(CSRF_HEADER).and_then(|v| v.to_str().ok()) == Some(CSRF_VALUE); + if safe || has_header { + next.run(req).await + } else { + (StatusCode::FORBIDDEN, axum::Json(serde_json::json!({ "error": "csrf" }))).into_response() + } +} + +async fn health(State(state): State) -> impl IntoResponse { + let db_ok = sqlx::query_scalar::<_, i64>("SELECT 1").fetch_one(&state.db).await.is_ok(); + axum::Json(serde_json::json!({ "ok": db_ok, "build": state.cfg.build })) +} + +pub fn api_router() -> Router { + Router::new() + .route("/health", get(health)) + .route("/auth/config", get(auth::config)) + .route("/auth/g1/start", get(auth::g1_start)) + .route("/auth/g1/callback", get(auth::g1_callback)) + .route("/auth/invitation", post(auth::accept_invitation)) + .route("/auth/dev", post(auth::dev_login)) + .route("/auth/logout", post(auth::logout)) + .route("/me", get(auth::me)) + .route("/groups", get(docs::groups)) + .route("/groups/{slug}", get(docs::group)) + .route("/docs/{id}", get(docs::document)) + .route("/docs/{id}/preview", get(docs::preview)) + .route("/docs/{id}/assist", post(proposals::assist)) + .route("/docs/{id}/proposals", post(proposals::create)) + .route("/proposals/{id}", get(proposals::detail).patch(proposals::edit)) + .route("/proposals/{id}/submit", post(proposals::submit)) + .route("/proposals/{id}/withdraw", post(proposals::withdraw)) + .route("/proposals/{id}/split", post(proposals::split)) + .route("/proposals/{id}/vote", post(proposals::vote)) + .route("/proposals/{id}/signals", post(proposals::signal)) + .route("/proposals/{id}/translations/{lang}", put(proposals::translate)) + .route("/signals/{id}/lift", post(proposals::lift_signal)) + .route("/units/{id}/inertia", post(proposals::propose_inertia)) + .route("/feedback", post(access::send_feedback)) + .route("/access", get(access::overview)) + .route("/access/groups/{slug}/members", post(access::grant)) + .route("/access/groups/{slug}/members/{identity}", delete(access::revoke)) + .route("/access/invitations", post(access::invite)) + .route("/access/invitations/{hash}", delete(access::cancel_invitation)) + .route("/access/identities/{id}/admin", post(access::set_admin)) + .route("/access/feedback", get(access::feedback_list)) + .route("/access/feedback/{id}", post(access::feedback_status)) + .layer(middleware::from_fn(csrf_guard)) +} + +pub fn app(state: AppState) -> Router { + Router::new() + .nest("/api", api_router()) + .fallback_service(web::service(&state.cfg.web_dir)) + .layer(tower_http::limit::RequestBodyLimitLayer::new(256 * 1024)) + .layer(tower_http::compression::CompressionLayer::new()) + .layer(tower_http::trace::TraceLayer::new_for_http()) + .with_state(state) +} + +/// Ctrl-C, or SIGTERM from `docker stop`. +async fn shutdown() { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(unix)] + let term = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut s) => { + s.recv().await; + } + Err(_) => std::future::pending::<()>().await, + } + }; + #[cfg(not(unix))] + let term = std::future::pending::<()>(); + tokio::select! { + () = ctrl_c => {} + () = term => {} + } +} + +pub async fn run() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,sqlx=warn,tower_http=warn".into()), + ) + .init(); + let cfg = Config::from_env(); + tokio::fs::create_dir_all(&cfg.data_dir).await?; + let db_url = format!("sqlite://{}", cfg.data_dir.join("libredecision.db").display()); + let db = open_db(&db_url).await?; + let state = AppState::new(cfg, db).await?; + + seed::ensure(&state).await?; + auth::bootstrap(&state).await?; + tokio::spawn(wot::refresher(state.clone())); + tokio::spawn(publish::worker(state.clone())); + tokio::spawn(adoption::sweeper(state.clone())); + + let listener = tokio::net::TcpListener::bind(&state.cfg.bind).await?; + tracing::info!(bind = %state.cfg.bind, build = %state.cfg.build, "libreDecision listening"); + axum::serve(listener, app(state).into_make_service_with_connect_info::()) + .with_graceful_shutdown(shutdown()) + .await?; + Ok(()) +} diff --git a/crates/ld-server/src/main.rs b/crates/ld-server/src/main.rs new file mode 100644 index 0000000..a451e9d --- /dev/null +++ b/crates/ld-server/src/main.rs @@ -0,0 +1,6 @@ +//! libreDecision server — entry point. Everything else lives in the library. + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + ld_server::run().await +} diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs new file mode 100644 index 0000000..c5e0f9a --- /dev/null +++ b/crates/ld-server/src/proposals.rs @@ -0,0 +1,797 @@ +//! Writing: drafts, submission, corrections, votes, contradiction signals, +//! inertia changes, editorial translations. +//! +//! Editing is not voting (Yvv, 2026-10-21: « il ne faut pas que ça devienne un +//! système qui nécessite un vote pour corriger une coquille »): +//! - a **draft** is free: its author edits it at will, nobody votes on it; +//! - once **submitted**, a correction classified *typographic* by the engine +//! keeps every vote and is shown with its diff; a *substantive* one opens a +//! new revision and every earlier vote waits for its author to reconfirm; +//! - a text **in force** accepts typographic corrections from any member of +//! the group, recorded with their author and diff, and republished — a +//! change of meaning goes through a proposal. + +use crate::AppState; +use crate::adoption; +use crate::auth::{IdentityRow, User}; +use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow, UnitRow}; +use crate::error::{AppError, AppResult}; +use crate::util; +use axum::Json; +use axum::extract::{Path, State}; +use ld_engine::assist::{self, Hint}; +use ld_engine::edit::{self, EditKind}; +use ld_engine::threshold::InertiaPreset; +use serde::Deserialize; +use serde_json::json; + +const MAX_TITLE: usize = 120; +const MAX_BODY: usize = 4000; +const MIN_REASON: usize = 10; +const MAX_REASON: usize = 600; + +async fn load_proposal(state: &AppState, id: &str) -> AppResult { + sqlx::query_as::<_, ProposalRow>(&format!("{PROPOSAL_SELECT} WHERE p.id = ?")) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound) +} + +async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult { + sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?") + .bind(unit_id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound) +} + +/// Loads the document of a proposal and checks the person may open its group. +async fn guarded(state: &AppState, user: &IdentityRow, proposal_id: &str) -> AppResult<(ProposalRow, DocState)> { + let p = load_proposal(state, proposal_id).await?; + let doc_id = doc_of_unit(state, &p.unit_id).await?; + let st = docs::load_state(state, &doc_id).await?; + docs::require_open(state, user, &st.group).await?; + if p.status == "draft" && p.author_id.as_deref() != Some(user.id.as_str()) { + return Err(AppError::NotFound); + } + Ok((p, st)) +} + +fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> { + if title.trim().is_empty() || title.chars().count() > MAX_TITLE { + return Err(AppError::Invalid("title")); + } + if (needs_body && body.trim().is_empty()) || body.chars().count() > MAX_BODY { + return Err(AppError::Invalid("body")); + } + Ok(()) +} + +async fn record_revision( + state: &AppState, + proposal_id: &str, + revision: i64, + kind: &str, + title: &str, + body: &str, + author: Option<&str>, +) -> AppResult<()> { + sqlx::query( + "INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(proposal_id) + .bind(revision) + .bind(kind) + .bind(title) + .bind(body) + .bind(author) + .bind(util::now()) + .execute(&state.db) + .await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Assistant +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssistBody { + text: String, + proposal_id: Option, +} + +pub async fn assist( + State(state): State, + User(user): User, + Path(doc_id): Path, + Json(body): Json, +) -> AppResult> { + let st = docs::load_state(&state, &doc_id).await?; + docs::require_open(&state, &user, &st.group).await?; + let others: Vec<(String, String)> = st + .proposals + .iter() + .filter(|p| p.status != "draft" && Some(&p.id) != body.proposal_id.as_ref() && p.kind == "text") + .map(|p| (p.id.clone(), format!("{}\n{}", p.title, p.body))) + .collect(); + let hints: Vec<_> = assist::hints(&body.text, &others) + .into_iter() + .map(|h| match h { + Hint::Similar { target, score } => { + let p = st.proposals.iter().find(|p| p.id == target); + let unit = p.and_then(|p| st.units.iter().find(|u| u.id == p.unit_id)); + json!({ + "kind": "similar", "score": score, "proposalId": target, + "unitId": unit.map(|u| &u.id), "code": unit.map(|u| &u.code), + "title": p.map(|p| &p.title), "inForce": p.is_some_and(|p| p.status == "in_force"), + }) + } + other => serde_json::to_value(other).unwrap_or_default(), + }) + .collect(); + Ok(Json(json!({ "hints": hints }))) +} + +// --------------------------------------------------------------------------- +// Create, edit, submit, withdraw, split +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateBody { + kind: Option, + unit_id: Option, + after_unit_id: Option, + title: String, + #[serde(default)] + body: String, + #[serde(default)] + submit: bool, +} + +/// Position right after `after` (or at the end), between it and the next unit. +fn position_after(units: &[UnitRow], after: Option<&str>) -> f64 { + let mut positions: Vec = units.iter().map(|u| u.position).collect(); + positions.sort_by(f64::total_cmp); + let Some(anchor) = after.and_then(|a| units.iter().find(|u| u.id == a)).map(|u| u.position) else { + return positions.last().copied().unwrap_or(0.0) + 1.0; + }; + match positions.iter().find(|p| **p > anchor) { + Some(next) => (anchor + next) / 2.0, + None => anchor + 1.0, + } +} + +async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppResult { + let id = util::id(); + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND code LIKE 'N%'") + .bind(&st.doc.id) + .fetch_one(&state.db) + .await?; + sqlx::query("INSERT INTO units (id, doc_id, code, position, inertia, in_force_id, created_at) VALUES (?, ?, ?, ?, 'standard', NULL, ?)") + .bind(&id) + .bind(&st.doc.id) + .bind(format!("N{}", n + 1)) + .bind(position_after(&st.units, after)) + .bind(util::now()) + .execute(&state.db) + .await?; + Ok(id) +} + +struct NewProposal<'a> { + unit_id: &'a str, + kind: &'a str, + inertia_target: Option<&'a str>, + title: &'a str, + body: &'a str, + author: &'a str, + submit: bool, +} + +async fn insert_proposal(state: &AppState, n: NewProposal<'_>) -> AppResult { + let NewProposal { unit_id, kind, inertia_target, title, body, author, submit } = n; + let id = util::id(); + let now = util::now(); + sqlx::query( + "INSERT INTO proposals (id, unit_id, kind, inertia_target, title, body, status, origin, author_id, revision, + created_at, updated_at, submitted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'member', ?, 1, ?, ?, ?)", + ) + .bind(&id) + .bind(unit_id) + .bind(kind) + .bind(inertia_target) + .bind(title.trim()) + .bind(body.trim()) + .bind(if submit { "open" } else { "draft" }) + .bind(author) + .bind(&now) + .bind(&now) + .bind(submit.then_some(&now)) + .execute(&state.db) + .await?; + record_revision(state, &id, 1, "initial", title.trim(), body.trim(), Some(author)).await?; + Ok(id) +} + +pub async fn create( + State(state): State, + User(user): User, + Path(doc_id): Path, + Json(b): Json, +) -> AppResult> { + let st = docs::load_state(&state, &doc_id).await?; + docs::require_open(&state, &user, &st.group).await?; + let kind = match b.kind.as_deref().unwrap_or("text") { + "text" => "text", + "remove" => "remove", + _ => return Err(AppError::Invalid("kind")), + }; + check_text(&b.title, &b.body, kind == "text")?; + let unit_id = match b.unit_id.as_deref() { + Some(u) => { + let unit = st.units.iter().find(|x| x.id == u).ok_or(AppError::NotFound)?; + if kind == "remove" && unit.in_force_id.is_none() { + return Err(AppError::Invalid("nothing_to_remove")); + } + unit.id.clone() + } + None if kind == "text" => new_unit(&state, &st, b.after_unit_id.as_deref()).await?, + None => return Err(AppError::Invalid("unit")), + }; + let id = insert_proposal( + &state, + NewProposal { + unit_id: &unit_id, + kind, + inertia_target: None, + title: &b.title, + body: &b.body, + author: &user.id, + submit: b.submit, + }, + ) + .await?; + Ok(Json(json!({ "id": id, "unitId": unit_id }))) +} + +#[derive(Deserialize)] +pub struct EditBody { + title: String, + body: String, +} + +pub async fn edit( + State(state): State, + User(user): User, + Path(id): Path, + Json(b): Json, +) -> AppResult> { + let (p, st) = guarded(&state, &user, &id).await?; + check_text(&b.title, &b.body, p.kind == "text")?; + let (title, body) = (b.title.trim(), b.body.trim()); + let is_author = p.author_id.as_deref() == Some(user.id.as_str()); + let now = util::now(); + + if p.status == "draft" { + if !is_author { + return Err(AppError::Forbidden); + } + sqlx::query("UPDATE proposals SET title = ?, body = ?, updated_at = ? WHERE id = ?") + .bind(title) + .bind(body) + .bind(&now) + .bind(&id) + .execute(&state.db) + .await?; + return Ok(Json(json!({ "edit": "draft" }))); + } + if !matches!(p.status.as_str(), "open" | "in_force") { + return Err(AppError::Conflict("closed")); + } + let kind = edit::classify(&format!("{}\n{}", p.title, p.body), &format!("{title}\n{body}")); + match kind { + EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))), + EditKind::Typographic => { + // Anyone in the group may fix a typo of a seed text or of a text in force. + if !(is_author || p.origin == "seed" || p.status == "in_force") { + return Err(AppError::Forbidden); + } + sqlx::query("UPDATE proposals SET title = ?, body = ?, updated_at = ? WHERE id = ?") + .bind(title) + .bind(body) + .bind(&now) + .bind(&id) + .execute(&state.db) + .await?; + record_revision(&state, &id, p.revision, "typo", title, body, Some(&user.id)).await?; + // A typo fix does not change the meaning: up-to-date translations stay up to date. + sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?") + .bind(util::source_hash(title, body)) + .bind(&id) + .bind(util::source_hash(&p.title, &p.body)) + .execute(&state.db) + .await?; + if p.status == "in_force" { + adoption::enqueue_publication(&state, &st.doc, "correction typographique").await?; + } + Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&p.body, body) }))) + } + EditKind::Substantive => { + if p.status == "in_force" { + return Err(AppError::Conflict("substantive_in_force")); + } + if !is_author { + return Err(AppError::Conflict("substantive_not_author")); + } + let revision = p.revision + 1; + sqlx::query("UPDATE proposals SET title = ?, body = ?, revision = ?, updated_at = ? WHERE id = ?") + .bind(title) + .bind(body) + .bind(revision) + .bind(&now) + .bind(&id) + .execute(&state.db) + .await?; + record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?; + // Signals were about the former text: they lapse, their authors may signal again. + sqlx::query( + "UPDATE signals SET lifted_at = ?, lifted_by = 'revision' WHERE proposal_id = ? AND lifted_at IS NULL", + ) + .bind(&now) + .bind(&id) + .execute(&state.db) + .await?; + Ok(Json(json!({ "edit": "substantive", "revision": revision }))) + } + } +} + +pub async fn submit( + State(state): State, + User(user): User, + Path(id): Path, +) -> AppResult> { + let (p, st) = guarded(&state, &user, &id).await?; + if p.author_id.as_deref() != Some(user.id.as_str()) { + return Err(AppError::Forbidden); + } + if p.status != "draft" { + return Err(AppError::Conflict("not_draft")); + } + let now = util::now(); + sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?") + .bind(&now) + .bind(&now) + .bind(&id) + .execute(&state.db) + .await?; + let entered = adoption::run(&state, &st.doc.id).await?; + Ok(Json(json!({ "status": "open", "entered": entered }))) +} + +pub async fn withdraw( + State(state): State, + User(user): User, + Path(id): Path, +) -> AppResult> { + let (p, _) = guarded(&state, &user, &id).await?; + if p.author_id.as_deref() != Some(user.id.as_str()) { + return Err(AppError::Forbidden); + } + if !matches!(p.status.as_str(), "draft" | "open") { + return Err(AppError::Conflict("closed")); + } + sqlx::query("UPDATE proposals SET status = 'withdrawn', left_at = ?, updated_at = ? WHERE id = ?") + .bind(util::now()) + .bind(util::now()) + .bind(&id) + .execute(&state.db) + .await?; + Ok(Json(json!({ "status": "withdrawn" }))) +} + +#[derive(Deserialize)] +pub struct SplitBody { + parts: Vec, +} + +/// One draft becomes several: the first part stays, each other part becomes a +/// draft on a new unit placed right after. Drafts only — nobody voted yet. +pub async fn split( + State(state): State, + User(user): User, + Path(id): Path, + Json(b): Json, +) -> AppResult> { + let (p, st) = guarded(&state, &user, &id).await?; + if p.author_id.as_deref() != Some(user.id.as_str()) { + return Err(AppError::Forbidden); + } + if p.status != "draft" || p.kind != "text" { + return Err(AppError::Conflict("not_draft")); + } + let parts: Vec<&str> = b.parts.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + if parts.len() < 2 || parts.len() > 12 || parts.iter().any(|s| s.chars().count() > MAX_BODY) { + return Err(AppError::Invalid("parts")); + } + sqlx::query("UPDATE proposals SET body = ?, updated_at = ? WHERE id = ?") + .bind(parts[0]) + .bind(util::now()) + .bind(&id) + .execute(&state.db) + .await?; + let mut created = vec![id.clone()]; + let mut after = p.unit_id.clone(); + for (n, part) in parts.iter().enumerate().skip(1) { + let fresh = docs::load_state(&state, &st.doc.id).await?; + let unit = new_unit(&state, &fresh, Some(&after)).await?; + let title = format!("{} ({})", p.title, n + 1); + let title: String = title.chars().take(MAX_TITLE).collect(); + created.push( + insert_proposal( + &state, + NewProposal { + unit_id: &unit, + kind: "text", + inertia_target: None, + title: &title, + body: part, + author: &user.id, + submit: false, + }, + ) + .await?, + ); + after = unit; + } + Ok(Json(json!({ "drafts": created }))) +} + +// --------------------------------------------------------------------------- +// Detail +// --------------------------------------------------------------------------- + +pub async fn detail( + State(state): State, + User(user): User, + Path(id): Path, +) -> AppResult> { + let (p, st) = guarded(&state, &user, &id).await?; + let unit = st.units.iter().find(|u| u.id == p.unit_id).ok_or(AppError::NotFound)?; + let in_force = match &unit.in_force_id { + Some(fid) if *fid != p.id => Some(load_proposal(&state, fid).await?), + _ => None, + }; + + #[derive(sqlx::FromRow, serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct Rev { + revision: i64, + kind: String, + title: String, + body: String, + author_name: Option, + created_at: String, + } + let revs = sqlx::query_as::<_, Rev>( + "SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name, r.created_at + FROM revisions r LEFT JOIN identities i ON i.id = r.author_id WHERE r.proposal_id = ? ORDER BY r.id", + ) + .bind(&id) + .fetch_all(&state.db) + .await?; + // Each correction with the diff from the text before it. + let mut history = Vec::new(); + let mut previous: Option<&Rev> = None; + for r in &revs { + let diff = previous + .map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body))); + history.push(json!({ + "revision": r.revision, "kind": r.kind, "author": r.author_name, "at": r.created_at, "diff": diff, + })); + previous = Some(r); + } + + #[derive(sqlx::FromRow)] + struct Sig { + id: String, + proposal_id: String, + target_proposal_id: String, + reason: String, + author_id: String, + author_name: String, + created_at: String, + lifted_at: Option, + lifted_by: Option, + } + let sigs = sqlx::query_as::<_, Sig>( + "SELECT s.id, s.proposal_id, s.target_proposal_id, s.reason, s.author_id, i.display_name AS author_name, + s.created_at, s.lifted_at, s.lifted_by + FROM signals s JOIN identities i ON i.id = s.author_id + WHERE s.proposal_id = ? OR s.target_proposal_id = ? ORDER BY s.created_at DESC", + ) + .bind(&id) + .bind(&id) + .fetch_all(&state.db) + .await?; + let mut signals = Vec::new(); + for s in sigs { + let other_id = if s.proposal_id == id { &s.target_proposal_id } else { &s.proposal_id }; + let other = load_proposal(&state, other_id).await.ok(); + let other_code = match &other { + Some(o) => st.units.iter().find(|u| u.id == o.unit_id).map(|u| u.code.clone()), + None => None, + }; + signals.push(json!({ + "id": s.id, "reason": s.reason, "author": s.author_name, "mine": s.author_id == user.id, + "at": s.created_at, "liftedAt": s.lifted_at, "liftedBy": s.lifted_by, + "other": other.map(|o| json!({ "id": o.id, "title": o.title, "status": o.status, "code": other_code })), + })); + } + + let my_vote: Option<(String, i64)> = + sqlx::query_as("SELECT choice, revision FROM votes WHERE proposal_id = ? AND identity_id = ?") + .bind(&id) + .bind(&user.id) + .fetch_optional(&state.db) + .await?; + let diff = match (&in_force, p.kind.as_str()) { + (Some(v), "text") => Some(edit::diff(&v.body, &p.body)), + _ => None, + }; + let eval = st.evaluation(unit, &p); + let voted_at = if p.kind == "inertia" { docs::inertia_of(unit).raised() } else { docs::inertia_of(unit) }; + let tr = docs::translations_for(&state, &[&p]).await?; + Ok(Json(json!({ + "proposal": { + "id": p.id, "kind": p.kind, "inertiaTarget": p.inertia_target, "title": p.title, "body": p.body, + "status": p.status, "origin": p.origin, "label": p.label, "revision": p.revision, + "author": p.author_name, "mine": p.author_id.as_deref() == Some(user.id.as_str()), + "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at, + "seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", + "tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at.as_str(), + "blocked": st.blocked.contains(&p.id), + "myVote": my_vote.as_ref().map(|v| &v.0), "myVoteStale": my_vote.as_ref().is_some_and(|v| v.1 != p.revision), + "translations": tr.get(&p.id).cloned().unwrap_or_default(), + }, + "unit": { "id": unit.id, "code": unit.code, "inertia": unit.inertia }, + "inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })), + "diff": diff, + "history": history, + "signals": signals, + "electorate": st.electorate, + "voteFloor": st.doc.vote_floor, + }))) +} + +// --------------------------------------------------------------------------- +// Votes +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct VoteBody { + choice: Option, +} + +pub async fn vote( + State(state): State, + User(user): User, + Path(id): Path, + Json(b): Json, +) -> AppResult> { + let (p, st) = guarded(&state, &user, &id).await?; + if !matches!(p.status.as_str(), "open" | "in_force") { + return Err(AppError::Conflict("not_votable")); + } + match b.choice.as_deref() { + Some(choice @ ("for" | "against")) => { + sqlx::query( + "INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(proposal_id, identity_id) DO UPDATE SET choice = excluded.choice, + revision = excluded.revision, cast_at = excluded.cast_at", + ) + .bind(&id) + .bind(&user.id) + .bind(choice) + .bind(p.revision) + .bind(util::now()) + .execute(&state.db) + .await?; + } + None => { + sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?") + .bind(&id) + .bind(&user.id) + .execute(&state.db) + .await?; + } + Some(_) => return Err(AppError::Invalid("choice")), + } + let entered = adoption::run(&state, &st.doc.id).await?; + Ok(Json(json!({ "entered": entered }))) +} + +// --------------------------------------------------------------------------- +// Contradiction signals +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SignalBody { + target_id: String, + reason: String, +} + +/// « These two texts contradict each other. » While the signal stands, the two +/// can never be in force together: the one not yet in force is blocked. +pub async fn signal( + State(state): State, + User(user): User, + Path(id): Path, + Json(b): Json, +) -> AppResult> { + let (p, st) = guarded(&state, &user, &id).await?; + let reason = b.reason.trim(); + if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON { + return Err(AppError::Invalid("reason")); + } + if b.target_id == id { + return Err(AppError::Invalid("target")); + } + let target = st.proposals.iter().find(|t| t.id == b.target_id).ok_or(AppError::Invalid("target"))?; + if target.status == "draft" || !matches!(p.status.as_str(), "open" | "in_force") { + return Err(AppError::Invalid("target")); + } + if target.unit_id == p.unit_id && target.kind == "text" && p.kind == "text" { + // Rivals on one unit never stand together anyway. + return Err(AppError::Invalid("same_unit")); + } + let sid = util::id(); + sqlx::query( + "INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&sid) + .bind(&id) + .bind(&b.target_id) + .bind(reason) + .bind(&user.id) + .bind(util::now()) + .execute(&state.db) + .await?; + Ok(Json(json!({ "id": sid }))) +} + +pub async fn lift_signal( + State(state): State, + User(user): User, + Path(id): Path, +) -> AppResult> { + let (proposal_id, author): (String, String) = + sqlx::query_as("SELECT proposal_id, author_id FROM signals WHERE id = ? AND lifted_at IS NULL") + .bind(&id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + let (_, st) = guarded(&state, &user, &proposal_id).await?; + if author != user.id && !user.admin { + return Err(AppError::Forbidden); + } + sqlx::query("UPDATE signals SET lifted_at = ?, lifted_by = ? WHERE id = ?") + .bind(util::now()) + .bind(&user.id) + .bind(&id) + .execute(&state.db) + .await?; + let entered = adoption::run(&state, &st.doc.id).await?; + Ok(Json(json!({ "entered": entered }))) +} + +// --------------------------------------------------------------------------- +// Inertia +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct InertiaBody { + target: String, +} + +/// Propose a change of inertia; it is voted one level above the unit. An open +/// proposal for the same target is reused rather than duplicated. +pub async fn propose_inertia( + State(state): State, + User(user): User, + Path(unit_id): Path, + Json(b): Json, +) -> AppResult> { + let target = InertiaPreset::parse(&b.target).ok_or(AppError::Invalid("inertia"))?; + let doc_id = doc_of_unit(&state, &unit_id).await?; + let st = docs::load_state(&state, &doc_id).await?; + docs::require_open(&state, &user, &st.group).await?; + let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?; + if docs::inertia_of(unit) == target { + return Err(AppError::Invalid("same_inertia")); + } + if let Some(existing) = st.proposals.iter().find(|p| { + p.unit_id == unit_id + && p.kind == "inertia" + && p.status == "open" + && p.inertia_target.as_deref() == Some(target.as_str()) + }) { + return Ok(Json(json!({ "id": existing.id, "existing": true }))); + } + let title = format!("inertia:{}", target.as_str()); + let id = insert_proposal( + &state, + NewProposal { + unit_id: &unit_id, + kind: "inertia", + inertia_target: Some(target.as_str()), + title: &title, + body: "", + author: &user.id, + submit: true, + }, + ) + .await?; + Ok(Json(json!({ "id": id, "existing": false }))) +} + +// --------------------------------------------------------------------------- +// Editorial translations +// --------------------------------------------------------------------------- + +pub async fn translate( + State(state): State, + User(user): User, + Path((id, lang)): Path<(String, String)>, + Json(b): Json, +) -> AppResult> { + let (p, st) = guarded(&state, &user, &id).await?; + if !util::is_lang(&lang) || lang == st.doc.source_lang { + return Err(AppError::Invalid("lang")); + } + check_text(&b.title, &b.body, p.kind == "text")?; + sqlx::query( + "INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at) + VALUES (?, ?, ?, ?, ?, 'member', ?, ?) + ON CONFLICT(proposal_id, lang) DO UPDATE SET title = excluded.title, body = excluded.body, + source_hash = excluded.source_hash, origin = 'member', updated_by = excluded.updated_by, + updated_at = excluded.updated_at", + ) + .bind(&id) + .bind(&lang) + .bind(b.title.trim()) + .bind(b.body.trim()) + .bind(util::source_hash(&p.title, &p.body)) + .bind(&user.id) + .bind(util::now()) + .execute(&state.db) + .await?; + if p.status == "in_force" { + adoption::enqueue_publication(&state, &st.doc, "traduction").await?; + } + Ok(Json(json!({ "ok": true }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unit(id: &str, position: f64) -> UnitRow { + UnitRow { id: id.into(), code: id.into(), position, inertia: "standard".into(), in_force_id: None } + } + + #[test] + fn new_units_slot_between_neighbours() { + let units = [unit("a", 1.0), unit("b", 2.0), unit("c", 4.0)]; + assert_eq!(position_after(&units, Some("a")), 1.5); + assert_eq!(position_after(&units, Some("c")), 5.0); + assert_eq!(position_after(&units, None), 5.0); + assert_eq!(position_after(&[], None), 1.0); + } +} diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs new file mode 100644 index 0000000..17b7c5c --- /dev/null +++ b/crates/ld-server/src/publish.rs @@ -0,0 +1,251 @@ +//! Publication of the texts in force to the git repository that client +//! applications read to update themselves (Yvv, 2026-10-21: « Le tout est +//! automatique poussé sur le dépôt git exploité par les applis clientes »). +//! +//! Every adoption, typo fix or translation of a text in force queues an +//! outbox row. The worker renders the document — one Markdown file per +//! language plus a JSON file — commits it in the server's own clone and pushes +//! with a deploy key. A failure is retried with backoff; the vote never waits +//! for the network. + +use crate::AppState; +use crate::docs::{self, DocState}; +use crate::util; +use serde_json::json; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::process::Command; + +const IDLE: Duration = Duration::from_secs(60); +const MAX_BACKOFF_SECS: i64 = 6 * 3600; + +pub async fn worker(state: AppState) { + loop { + tokio::select! { + () = state.publish_wake.notified() => {} + () = tokio::time::sleep(IDLE) => {} + } + if state.cfg.publish.is_none() { + continue; + } + if let Err(e) = pass(&state).await { + tracing::warn!(error = %e, "publication pass"); + } + } +} + +#[derive(sqlx::FromRow)] +struct Due { + id: i64, + doc_id: String, + attempts: i64, +} + +async fn pass(state: &AppState) -> anyhow::Result<()> { + let due = sqlx::query_as::<_, Due>( + "SELECT id, doc_id, attempts FROM outbox WHERE done_at IS NULL AND next_at <= ? ORDER BY id", + ) + .bind(util::now()) + .fetch_all(&state.db) + .await?; + let mut docs_due: Vec = due.iter().map(|d| d.doc_id.clone()).collect(); + docs_due.dedup(); + for doc_id in docs_due { + let rows: Vec<&Due> = due.iter().filter(|d| d.doc_id == doc_id).collect(); + let last = rows.iter().map(|d| d.id).max().unwrap_or(0); + let attempts = rows.iter().map(|d| d.attempts).max().unwrap_or(0); + match publish_doc(state, &doc_id).await { + Ok(sha) => { + sqlx::query("UPDATE outbox SET done_at = ?, commit_sha = ?, last_error = NULL WHERE doc_id = ? AND done_at IS NULL AND id <= ?") + .bind(util::now()) + .bind(&sha) + .bind(&doc_id) + .bind(last) + .execute(&state.db) + .await?; + tracing::info!(doc = %doc_id, commit = sha.as_deref().unwrap_or("unchanged"), "published"); + } + Err(e) => { + let wait = (60_i64 << attempts.min(12)).min(MAX_BACKOFF_SECS); + sqlx::query("UPDATE outbox SET attempts = attempts + 1, last_error = ?, next_at = ? WHERE doc_id = ? AND done_at IS NULL") + .bind(format!("{e:#}").chars().take(500).collect::()) + .bind(util::in_seconds(wait)) + .bind(&doc_id) + .execute(&state.db) + .await?; + tracing::warn!(doc = %doc_id, error = %e, retry_in = wait, "publication failed"); + } + } + } + Ok(()) +} + +async fn git(dir: &Path, state: &AppState, args: &[&str]) -> anyhow::Result { + let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?; + let known_hosts = state.cfg.data_dir.join("keys/known_hosts"); + let ssh = format!( + "ssh -i {} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile={}", + publish.key.display(), + known_hosts.display() + ); + let out = Command::new("git") + .args([ + "-c", + &format!("user.name={}", publish.author_name), + "-c", + &format!("user.email={}", publish.author_email), + ]) + .args(args) + .current_dir(dir) + .env("GIT_SSH_COMMAND", ssh) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .await?; + if !out.status.success() { + anyhow::bail!("git {}: {}", args.first().unwrap_or(&""), String::from_utf8_lossy(&out.stderr).trim()); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) +} + +/// The server's own clone: a cache of the remote, realigned on it before each +/// commit so a push never needs a merge. +async fn checkout(state: &AppState) -> anyhow::Result { + let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?; + let dir = state.cfg.data_dir.join("textes"); + if !dir.join(".git").exists() { + tokio::fs::create_dir_all(&dir).await?; + git(&dir, state, &["init", "-q"]).await?; + git(&dir, state, &["remote", "add", "origin", &publish.remote]).await?; + } + git(&dir, state, &["remote", "set-url", "origin", &publish.remote]).await?; + let has_remote_branch = git(&dir, state, &["ls-remote", "--heads", "origin", &publish.branch]).await?; + if has_remote_branch.is_empty() { + git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch]).await?; + } else { + // This clone is a cache: whatever a failed pass left is re-rendered from the database. + git(&dir, state, &["fetch", "-q", "origin", &publish.branch]).await?; + git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch, &format!("origin/{}", publish.branch)]) + .await?; + } + Ok(dir) +} + +async fn publish_doc(state: &AppState, doc_id: &str) -> anyhow::Result> { + let st = docs::load_state(state, doc_id).await.map_err(|e| anyhow::anyhow!("{e}"))?; + let Some(path) = st.doc.publish_path.clone() else { return Ok(None) }; + let rendered = render(state, &st).await?; + let dir = checkout(state).await?; + let target = dir.join(&path); + tokio::fs::create_dir_all(&target).await?; + for (name, content) in &rendered { + tokio::fs::write(target.join(name), content).await?; + } + let readme = dir.join("README.md"); + if !readme.exists() { + tokio::fs::write(&readme, README).await?; + } + git(&dir, state, &["add", "-A", "--", &path, "README.md"]).await?; + let staged = git(&dir, state, &["diff", "--cached", "--name-only"]).await?; + if staged.is_empty() { + return Ok(None); + } + let reasons: Vec = + sqlx::query_scalar("SELECT reason FROM outbox WHERE doc_id = ? AND done_at IS NULL ORDER BY id") + .bind(doc_id) + .fetch_all(&state.db) + .await?; + let title = util::parse_lang(&st.doc.title).get(&st.doc.source_lang).cloned().unwrap_or_default(); + let mut message = format!("{title} — {}", reasons.last().cloned().unwrap_or_else(|| "mise à jour".into())); + if reasons.len() > 1 { + message.push_str("\n\n"); + for r in &reasons { + message.push_str(&format!("- {r}\n")); + } + } + git(&dir, state, &["commit", "-q", "-m", &message]).await?; + let branch = state.cfg.publish.as_ref().map(|p| p.branch.clone()).unwrap_or_else(|| "main".into()); + git(&dir, state, &["push", "-q", "origin", &format!("HEAD:{branch}")]).await?; + Ok(Some(git(&dir, state, &["rev-parse", "HEAD"]).await?)) +} + +const README: &str = "# Textes en vigueur — libreDecision\n\n\ +Ce dépôt est écrit par libreDecision (https://decision.librodrome.org) : chaque fois qu'un article \ +entre en vigueur par le vote, qu'une coquille est corrigée ou qu'une traduction change, le texte en \ +vigueur est republié ici. Les applications clientes le lisent pour se mettre à jour.\n\n\ +- `//fr.md` : le texte qui fait foi (langue source) ;\n\ +- `en.md`, `es.md` : traductions éditoriales, sans vote ;\n\ +- `document.json` : le même contenu, structuré, avec l'état du vote de chaque article.\n\n\ +Ne pas modifier à la main : le prochain passage de libreDecision réécrit ces fichiers.\n"; + +/// Markdown per language + JSON. Only the units in force, in order. +pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result> { + let titles = util::parse_lang(&st.doc.title); + let src = st.doc.source_lang.clone(); + let in_force: Vec<_> = st + .units + .iter() + .filter_map(|u| { + let fid = u.in_force_id.as_ref()?; + st.proposals.iter().find(|p| &p.id == fid).map(|p| (u, p)) + }) + .collect(); + let refs: Vec<&docs::ProposalRow> = in_force.iter().map(|(_, p)| *p).collect(); + let tr = docs::translations_for(state, &refs).await.map_err(|e| anyhow::anyhow!("{e}"))?; + let updated = util::now(); + let source_url = format!("{}/g/{}/docs/{}", state.cfg.public_url, st.group.slug, st.doc.slug); + let mut files = Vec::new(); + for lang in util::LANGS { + let title = titles.get(lang).or_else(|| titles.get(&src)).cloned().unwrap_or_default(); + let mut md = format!( + "---\ndocument: {}\nlangue: {lang}\nsource: {source_url}\nmise_a_jour: {updated}\n{}---\n\n# {title}\n", + st.doc.slug, + if lang == src { "fait_foi: oui\n".to_owned() } else { "traduction: editoriale\n".to_owned() }, + ); + for (_, p) in &in_force { + let t = tr.get(&p.id).and_then(|m| m.get(lang)); + let (ut, ub, fallback) = match t { + Some(t) if lang != src => ( + t["title"].as_str().unwrap_or(&p.title).to_owned(), + t["body"].as_str().unwrap_or(&p.body).to_owned(), + t["stale"].as_bool().unwrap_or(false), + ), + _ => (p.title.clone(), p.body.clone(), lang != src), + }; + md.push_str(&format!("\n## {ut}\n\n{ub}\n")); + if fallback { + md.push_str(&format!( + "\n\n" + )); + } + } + files.push((format!("{lang}.md"), md)); + } + let units: Vec<_> = in_force + .iter() + .map(|(u, p)| { + let mut title = serde_json::Map::new(); + let mut body = serde_json::Map::new(); + title.insert(src.clone(), json!(p.title)); + body.insert(src.clone(), json!(p.body)); + if let Some(m) = tr.get(&p.id) { + for (lang, t) in m { + title.insert(lang.clone(), t["title"].clone()); + body.insert(lang.clone(), t["body"].clone()); + } + } + let tally = st.tally(&p.id); + json!({ + "code": u.code, "title": title, "body": body, "inertia": u.inertia, + "status": if p.entered_at.is_some() { "adopted" } else if p.confirmed_at.is_some() { "confirmed" } else { "seed-minimal" }, + "enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against }, + }) + }) + .collect(); + let json = json!({ + "document": st.doc.slug, "group": st.group.slug, "title": titles, "sourceLang": src, + "source": source_url, "updatedAt": updated, "electorate": st.electorate, + "voteFloor": st.doc.vote_floor, "units": units, + }); + files.push(("document.json".into(), format!("{}\n", serde_json::to_string_pretty(&json)?))); + Ok(files) +} diff --git a/crates/ld-server/src/seed.rs b/crates/ld-server/src/seed.rs new file mode 100644 index 0000000..d2f06b1 --- /dev/null +++ b/crates/ld-server/src/seed.rs @@ -0,0 +1,204 @@ +//! Seeds — so as not to start from a blank page. +//! +//! On start, missing groups are created and every `seeds/*.json` document is +//! instantiated in each group it names, **only if that group does not hold it +//! yet**. An existing document is never touched: from its first vote on, it +//! is runtime data. + +use crate::AppState; +use crate::util::{self, LangMap}; +use serde::Deserialize; +use std::path::Path; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeedDoc { + pub slug: String, + pub title: LangMap, + pub summary: LangMap, + pub source_lang: String, + pub instances: Vec, + pub units: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeedInstance { + pub group: String, + pub vote_floor: f64, + pub publish_path: Option, +} + +#[derive(Deserialize)] +pub struct SeedUnit { + pub code: String, + pub position: f64, + pub inertia: String, + pub versions: Vec, +} + +#[derive(Deserialize)] +pub struct SeedVersion { + pub label: Option, + /// `in_force` (seed a minima) or `open` (proposal at zero votes). + pub status: String, + pub title: LangMap, + pub body: LangMap, +} + +pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> { + for (position, g) in trustin_access::default_groups().into_iter().enumerate() { + sqlx::query( + "INSERT OR IGNORE INTO groups (slug, name, parent, policy, hidden, position) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&g.slug) + .bind(&g.name) + .bind(&g.parent) + .bind(g.policy.as_str()) + .bind(g.hidden) + .bind(i64::try_from(position).unwrap_or(0)) + .execute(&state.db) + .await?; + } + Ok(()) +} + +pub async fn ensure(state: &AppState) -> anyhow::Result<()> { + ensure_groups(state).await?; + let dir = state.cfg.seed_dir.clone(); + let mut entries = match tokio::fs::read_dir(&dir).await { + Ok(e) => e, + Err(e) => { + tracing::warn!(dir = %dir.display(), error = %e, "no seed directory"); + return Ok(()); + } + }; + let mut files = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|x| x == "json") { + files.push(path); + } + } + files.sort(); + for path in files { + load_file(state, &path).await?; + } + Ok(()) +} + +async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<()> { + let raw = tokio::fs::read_to_string(path).await?; + let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?; + for inst in &seed.instances { + let group_exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM groups WHERE slug = ?") + .bind(&inst.group) + .fetch_one(&state.db) + .await?; + if group_exists == 0 { + tracing::warn!(group = %inst.group, slug = %seed.slug, "seed names an unknown group"); + continue; + } + let present: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents WHERE group_slug = ? AND slug = ?") + .bind(&inst.group) + .bind(&seed.slug) + .fetch_one(&state.db) + .await?; + if present > 0 { + continue; + } + instantiate(state, &seed, inst).await?; + tracing::info!(group = %inst.group, slug = %seed.slug, "seed instantiated"); + } + Ok(()) +} + +async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> { + let src = seed.source_lang.as_str(); + let pick = |m: &LangMap| m.get(src).cloned().unwrap_or_default(); + let now = util::now(); + let doc_id = util::id(); + let mut tx = state.db.begin().await?; + sqlx::query( + "INSERT INTO documents (id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&doc_id) + .bind(&inst.group) + .bind(&seed.slug) + .bind(util::lang_json(&seed.title)) + .bind(util::lang_json(&seed.summary)) + .bind(src) + .bind(inst.vote_floor) + .bind(&inst.publish_path) + .bind(&now) + .execute(&mut *tx) + .await?; + for unit in &seed.units { + let unit_id = util::id(); + sqlx::query("INSERT INTO units (id, doc_id, code, position, inertia, in_force_id, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?)") + .bind(&unit_id) + .bind(&doc_id) + .bind(&unit.code) + .bind(unit.position) + .bind(&unit.inertia) + .bind(&now) + .execute(&mut *tx) + .await?; + for v in &unit.versions { + let pid = util::id(); + let (title, body) = (pick(&v.title), pick(&v.body)); + let status = if v.status == "in_force" { "in_force" } else { "open" }; + sqlx::query( + "INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, label, author_id, revision, + created_at, updated_at, submitted_at) + VALUES (?, ?, 'text', ?, ?, ?, 'seed', ?, NULL, 1, ?, ?, ?)", + ) + .bind(&pid) + .bind(&unit_id) + .bind(&title) + .bind(&body) + .bind(status) + .bind(&v.label) + .bind(&now) + .bind(&now) + .bind(&now) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, 1, 'initial', ?, ?, NULL, ?)", + ) + .bind(&pid) + .bind(&title) + .bind(&body) + .bind(&now) + .execute(&mut *tx) + .await?; + if status == "in_force" { + sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?") + .bind(&pid) + .bind(&unit_id) + .execute(&mut *tx) + .await?; + } + let hash = util::source_hash(&title, &body); + for lang in util::LANGS.iter().filter(|l| **l != src) { + let (Some(t), Some(b)) = (v.title.get(*lang), v.body.get(*lang)) else { continue }; + sqlx::query( + "INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at) + VALUES (?, ?, ?, ?, ?, 'seed', NULL, ?)", + ) + .bind(&pid) + .bind(lang) + .bind(t) + .bind(b) + .bind(&hash) + .bind(&now) + .execute(&mut *tx) + .await?; + } + } + } + tx.commit().await?; + Ok(()) +} diff --git a/crates/ld-server/src/util.rs b/crates/ld-server/src/util.rs new file mode 100644 index 0000000..9924c43 --- /dev/null +++ b/crates/ld-server/src/util.rs @@ -0,0 +1,63 @@ +//! Small shared helpers: clock, randomness, hashing, language maps. + +use rand::RngCore; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +/// Languages of the interface and of the editorial translations (Yvv, 2026-10-21). +pub const LANGS: [&str; 3] = ["fr", "en", "es"]; + +pub fn now() -> String { + stamp(OffsetDateTime::now_utc()) +} + +pub fn stamp(at: OffsetDateTime) -> String { + at.replace_nanosecond(0).unwrap_or(at).format(&Rfc3339).unwrap_or_default() +} + +pub fn in_days(days: i64) -> String { + stamp(OffsetDateTime::now_utc() + time::Duration::days(days)) +} + +pub fn in_seconds(secs: i64) -> String { + stamp(OffsetDateTime::now_utc() + time::Duration::seconds(secs)) +} + +/// 32 random bytes, hex — session tokens, invitation tokens, OAuth state. +pub fn token() -> String { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +/// 16 random bytes, hex — row identifiers. +pub fn id() -> String { + let mut bytes = [0u8; 16]; + rand::rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +pub fn sha256_hex(data: &str) -> String { + hex::encode(Sha256::digest(data.as_bytes())) +} + +/// The hash a translation is pinned to: it goes stale when the source changes. +pub fn source_hash(title: &str, body: &str) -> String { + sha256_hex(&format!("{title}\n\u{1f}\n{body}")) +} + +pub type LangMap = BTreeMap; + +pub fn lang_json(map: &LangMap) -> String { + serde_json::to_string(map).unwrap_or_else(|_| "{}".into()) +} + +pub fn parse_lang(raw: &str) -> LangMap { + serde_json::from_str(raw).unwrap_or_default() +} + +pub fn is_lang(lang: &str) -> bool { + LANGS.contains(&lang) +} diff --git a/crates/ld-server/src/web.rs b/crates/ld-server/src/web.rs new file mode 100644 index 0000000..0df36bb --- /dev/null +++ b/crates/ld-server/src/web.rs @@ -0,0 +1,30 @@ +//! The compiled Svelte interface: static files, SPA fallback, cache policy. + +use axum::body::Body; +use axum::http::Request; +use axum::http::header::{CACHE_CONTROL, HeaderValue}; +use axum::response::Response; +use std::path::Path; +use tower::ServiceExt; +use tower::util::BoxCloneSyncService; +use tower_http::services::{ServeDir, ServeFile}; + +pub type WebService = BoxCloneSyncService, Response, std::convert::Infallible>; + +/// Hashed assets are immutable; everything else (index.html) is revalidated. +pub fn service(dir: &Path) -> WebService { + let index = dir.join("index.html"); + let serve = ServeDir::new(dir).precompressed_gzip().fallback(ServeFile::new(index)); + let svc = tower::service_fn(move |req: Request| { + let immutable = req.uri().path().starts_with("/_app/immutable/"); + let serve = serve.clone(); + async move { + let res = serve.oneshot(req).await.unwrap_or_else(|e| match e {}); + let mut res = res.map(Body::new); + let policy = if immutable { "public, max-age=31536000, immutable" } else { "no-cache" }; + res.headers_mut().insert(CACHE_CONTROL, HeaderValue::from_static(policy)); + Ok::<_, std::convert::Infallible>(res) + } + }); + BoxCloneSyncService::new(svc) +} diff --git a/crates/ld-server/src/wot.rs b/crates/ld-server/src/wot.rs new file mode 100644 index 0000000..bb0a36b --- /dev/null +++ b/crates/ld-server/src/wot.rs @@ -0,0 +1,128 @@ +//! W — the size of the Ğ1 web of trust, the electorate of Monnaie libre. +//! +//! Read on chain first (`Membership.CounterForMembership`, JSON-RPC +//! `state_getStorage` on a Duniter v2s node), from an indexer otherwise, and +//! kept in `settings`. A failed refresh keeps the last known value; the very +//! first start falls back to `LD_WOT_FALLBACK`. + +use crate::AppState; +use crate::util; +use serde_json::json; +use std::time::Duration; + +const REFRESH: Duration = Duration::from_secs(6 * 3600); +const PLAUSIBLE: std::ops::RangeInclusive = 100..=10_000_000; + +fn twox_128(data: &[u8]) -> [u8; 16] { + let mut out = [0u8; 16]; + out[..8].copy_from_slice(&twox_hash::XxHash64::oneshot(0, data).to_le_bytes()); + out[8..].copy_from_slice(&twox_hash::XxHash64::oneshot(1, data).to_le_bytes()); + out +} + +/// Storage key of a plain storage value: `twox_128(pallet) ++ twox_128(item)`. +pub fn storage_key(pallet: &str, item: &str) -> String { + let mut key = Vec::with_capacity(32); + key.extend_from_slice(&twox_128(pallet.as_bytes())); + key.extend_from_slice(&twox_128(item.as_bytes())); + format!("0x{}", hex::encode(key)) +} + +/// SCALE `u32`, little endian. +pub fn decode_u32(hex_value: &str) -> Option { + let bytes = hex::decode(hex_value.trim_start_matches("0x")).ok()?; + let arr: [u8; 4] = bytes.get(..4)?.try_into().ok()?; + Some(u32::from_le_bytes(arr)) +} + +async fn from_chain(state: &AppState) -> Option<(u32, String)> { + let key = storage_key("Membership", "CounterForMembership"); + for node in &state.cfg.rpc_endpoints { + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "state_getStorage", "params": [key] }); + let res = state.http.post(node).json(&body).send().await; + let value: Option = match res { + Ok(r) if r.status().is_success() => r.json().await.ok(), + _ => None, + }; + if let Some(n) = value.as_ref().and_then(|v| v["result"].as_str()).and_then(decode_u32) + && PLAUSIBLE.contains(&n) + { + return Some((n, format!("chain:{node}"))); + } + } + None +} + +async fn from_indexer(state: &AppState) -> Option<(u32, String)> { + let query = json!({ "query": "{ identities(filter:{isMember:{equalTo:true}}) { totalCount } }" }); + for url in &state.cfg.indexers { + let res = state.http.post(url).json(&query).send().await; + let value: Option = match res { + Ok(r) if r.status().is_success() => r.json().await.ok(), + _ => None, + }; + let n = value + .as_ref() + .and_then(|v| v["data"]["identities"]["totalCount"].as_u64()) + .and_then(|n| u32::try_from(n).ok()); + if let Some(n) = n.filter(|n| PLAUSIBLE.contains(n)) { + return Some((n, format!("indexer:{url}"))); + } + } + None +} + +pub async fn refresh(state: &AppState) -> anyhow::Result> { + let found = match from_chain(state).await { + Some(v) => Some(v), + None => from_indexer(state).await, + }; + let Some((n, source)) = found else { + tracing::warn!("web of trust size: no node nor indexer answered, keeping the last value"); + return Ok(None); + }; + let now = util::now(); + for (key, value) in [("wot_size", n.to_string()), ("wot_source", source.clone())] { + sqlx::query( + "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", + ) + .bind(key) + .bind(value) + .bind(&now) + .execute(&state.db) + .await?; + } + tracing::info!(size = n, %source, "web of trust size"); + Ok(Some(n)) +} + +pub async fn refresher(state: AppState) { + let mut tick = tokio::time::interval(REFRESH); + loop { + tick.tick().await; + if let Err(e) = refresh(&state).await { + tracing::warn!(error = %e, "web of trust refresh"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn storage_keys_match_substrate() { + // Well-known key of System.Number (block number) on any Substrate chain. + assert_eq!( + storage_key("System", "Number"), + "0x26aa394eea5630e07c48ae0c9558cef702a5c1b19ab7a04f536c519aca4983ac" + ); + } + + #[test] + fn scale_u32() { + assert_eq!(decode_u32("0x71160000"), Some(5745)); + assert_eq!(decode_u32("0x01"), None); + } +} diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs new file mode 100644 index 0000000..c43e637 --- /dev/null +++ b/crates/ld-server/tests/api.rs @@ -0,0 +1,388 @@ +//! End-to-end API scenarios on an in-memory database seeded like production. + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use http_body_util::BodyExt; +use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +struct Client { + router: axum::Router, + cookie: Option, +} + +impl Client { + async fn new() -> (Self, AppState) { + let db = open_db("sqlite::memory:").await.expect("db"); + let state = AppState::new(Config::for_tests(), db).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + (Self { router: app(state.clone()), cookie: None }, state) + } + + fn with(&self, cookie: Option) -> Self { + Self { router: self.router.clone(), cookie } + } + + async fn call(&mut self, method: &str, uri: &str, body: Option) -> (StatusCode, Value) { + let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE); + if let Some(c) = &self.cookie { + req = req.header(header::COOKIE, c); + } + let req = match body { + Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())), + None => req.body(Body::empty()), + } + .expect("request"); + let res = self.router.clone().oneshot(req).await.expect("response"); + let status = res.status(); + if let Some(set) = res.headers().get(header::SET_COOKIE) { + let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned(); + if raw.starts_with("ld_session=") { + self.cookie = Some(raw); + } + } + let bytes = res.into_body().collect().await.expect("body").to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) + } + + async fn login(&mut self, name: &str, admin: bool) { + let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + } +} + +async fn doc_id(c: &mut Client, group: &str) -> String { + let (s, v) = c.call("GET", &format!("/api/groups/{group}"), None).await; + assert_eq!(s, StatusCode::OK, "{v}"); + v["docs"][0]["id"].as_str().expect("doc").to_owned() +} + +fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value { + doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") +} + +#[tokio::test] +async fn anonymous_sees_nothing_and_csrf_is_enforced() { + let (mut c, _) = Client::new().await; + let (s, _) = c.call("GET", "/api/groups", None).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + let (s, v) = c.call("GET", "/api/me", None).await; + assert_eq!(s, StatusCode::OK); + assert!(v["me"].is_null()); + // A mutating call without the header is refused. + let req = Request::builder() + .method("POST") + .uri("/api/auth/dev") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"name":"x"}"#)) + .expect("request"); + let res = c.router.clone().oneshot(req).await.expect("response"); + assert_eq!(res.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn the_seed_a_minima_and_the_open_proposals() { + let (mut c, _) = Client::new().await; + c.login("Chiara", false).await; + let (_, groups) = c.call("GET", "/api/groups", None).await; + let slugs: Vec<&str> = + groups["groups"].as_array().expect("groups").iter().filter_map(|g| g["slug"].as_str()).collect(); + assert_eq!(slugs, ["monnaie-libre", "axiom", "librodrome", "vallee-drome-crestois", "essai"]); + + let id = doc_id(&mut c, "monnaie-libre").await; + let (s, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await; + assert_eq!(s, StatusCode::OK); + let in_force: Vec<&str> = doc["units"] + .as_array() + .expect("units") + .iter() + .filter(|u| !u["inForce"].is_null()) + .filter_map(|u| u["code"].as_str()) + .collect(); + assert_eq!(in_force, ["I1", "E1", "E2"]); + let e1 = &unit(&doc, "E1")["inForce"]; + assert_eq!(e1["seedMinimal"], true); + assert_eq!(e1["tally"]["for"], 0); + assert_eq!(doc["doc"]["voteFloor"], 50.0); + assert_eq!(doc["doc"]["electorate"], 5745); + // Every other article is an open proposal at zero votes, standard inertia. + let e3 = unit(&doc, "E3"); + assert!(e3["inForce"].is_null()); + assert_eq!(e3["proposals"].as_array().expect("p").len(), 2, "E3 and its 0.4.0 variant"); + assert_eq!(e3["inertia"], "standard"); + // Translations seeded. + assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit")); +} + +#[tokio::test] +async fn one_vote_never_adopts_in_monnaie_libre() { + let (mut c, _) = Client::new().await; + c.login("Yvv", false).await; + let id = doc_id(&mut c, "monnaie-libre").await; + let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await; + let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned(); + let (s, v) = c.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["entered"].as_array().expect("e").len(), 0); + let (_, d) = c.call("GET", &format!("/api/proposals/{p}"), None).await; + assert_eq!(d["proposal"]["evaluation"]["required"], 50); + assert_eq!(d["proposal"]["evaluation"]["wellRated"], true); + assert_eq!(d["proposal"]["myVote"], "for"); +} + +#[tokio::test] +async fn adoption_in_the_test_group_and_publication_queued_only_where_published() { + let (mut a, state) = Client::new().await; + a.login("Yvv", false).await; + let mut b = a.with(None); + b.login("Chiara", false).await; + // Dev identities are members of every group: W(essai) = 2, C = 1. + let id = doc_id(&mut a, "essai").await; + let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; + let e3 = unit(&doc, "E3"); + let p = e3["proposals"][0]["id"].as_str().expect("p").to_owned(); + let (_, v) = a.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; + assert!(v["entered"].as_array().expect("e").is_empty(), "one of two is not enough"); + let (_, v) = b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; + assert_eq!(v["entered"][0]["code"], "E3"); + let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; + let e3 = unit(&doc, "E3"); + assert_eq!(e3["inForce"]["id"], p.as_str()); + assert!(e3["inForce"]["enteredAt"].is_string()); + // The test group publishes nothing. + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox").fetch_one(&state.db).await.expect("count"); + assert_eq!(n, 0); +} + +#[tokio::test] +async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() { + let (mut a, _) = Client::new().await; + a.login("Yvv", false).await; + let mut b = a.with(None); + b.login("Chiara", false).await; + let id = doc_id(&mut a, "essai").await; + let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; + let e1 = unit(&doc, "E1"); + let (uid, seed_id) = + (e1["id"].as_str().expect("u").to_owned(), e1["inForce"]["id"].as_str().expect("v").to_owned()); + let (s, v) = a + .call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ + "unitId": uid, "title": "Unicité du compte", + "body": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie Ğ1.", "submit": true, + }))) + .await; + assert_eq!(s, StatusCode::OK, "{v}"); + let rival = v["id"].as_str().expect("id").to_owned(); + a.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await; + let (_, v) = b.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await; + assert_eq!(v["entered"][0]["kind"], "text"); + let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; + let e1 = unit(&doc, "E1"); + assert_eq!(e1["inForce"]["id"], rival.as_str()); + assert!( + e1["proposals"].as_array().expect("p").iter().any(|p| p["id"] == seed_id.as_str() && p["status"] == "open") + ); +} + +#[tokio::test] +async fn editing_is_not_voting() { + let (mut a, _) = Client::new().await; + a.login("Yvv", false).await; + let mut b = a.with(None); + b.login("Chiara", false).await; + let id = doc_id(&mut a, "monnaie-libre").await; + let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; + let i1 = unit(&doc, "I1")["inForce"].clone(); + let pid = i1["id"].as_str().expect("id").to_owned(); + // Anyone may fix the glued word of the preamble in force, without a vote. + let fixed = i1["body"].as_str().expect("body").replace("latransmission", "la transmission"); + let (s, v) = + b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": fixed }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["edit"], "typo"); + // A change of meaning in force is refused: it goes through a proposal. + let (s, v) = b + .call( + "PATCH", + &format!("/api/proposals/{pid}"), + Some(json!({ "title": i1["title"], "body": "Tout autre chose." })), + ) + .await; + assert_eq!(s, StatusCode::CONFLICT); + assert_eq!(v["error"], "substantive_in_force"); + + // A member proposal: typo keeps votes, substance asks to reconfirm. + let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned(); + let (_, v) = a + .call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ + "unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des persones qui respectent ces engagements.", "submit": true, + }))) + .await; + let p = v["id"].as_str().expect("id").to_owned(); + b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; + // A misspelt word fixed: the vote stays. + let (_, v) = a + .call("PATCH", &format!("/api/proposals/{p}"), Some(json!({ + "title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes qui respectent ces engagements.", + }))) + .await; + assert_eq!(v["edit"], "typo"); + let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await; + assert_eq!(d["proposal"]["tally"]["for"], 1, "a typo keeps the votes"); + // Words added: a new revision, the vote waits for reconfirmation. + let (_, v) = a + .call("PATCH", &format!("/api/proposals/{p}"), Some(json!({ + "title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes qui respectent ces deux engagements fondamentaux.", + }))) + .await; + assert_eq!(v["edit"], "substantive"); + let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await; + assert_eq!(d["proposal"]["myVoteStale"], true); + assert_eq!(d["proposal"]["tally"]["for"], 0); + b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; + let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await; + assert_eq!(d["proposal"]["myVoteStale"], false); + assert_eq!(d["proposal"]["tally"]["for"], 1); + assert_eq!(d["history"].as_array().expect("h").len(), 3); + // Someone else cannot change the substance. + let (s, _) = b + .call( + "PATCH", + &format!("/api/proposals/{p}"), + Some(json!({ "title": "Réciprocité", "body": "Autre chose entièrement." })), + ) + .await; + assert_eq!(s, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn a_contradiction_signal_blocks_entering_into_force() { + let (mut a, _) = Client::new().await; + a.login("Yvv", false).await; + let mut b = a.with(None); + b.login("Chiara", false).await; + let id = doc_id(&mut a, "essai").await; + let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; + let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned(); + let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned(); + let (s, v) = b + .call( + "POST", + &format!("/api/proposals/{k1}/signals"), + Some(json!({ "targetId": e2, "reason": "Ces règles contredisent la réciprocité en vigueur." })), + ) + .await; + assert_eq!(s, StatusCode::OK, "{v}"); + let sid = v["id"].as_str().expect("sid").to_owned(); + a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await; + let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await; + assert!(v["entered"].as_array().expect("e").is_empty(), "blocked while the signal stands"); + // Only its author lifts it; then the pass lets the text in. + let (s, _) = a.call("POST", &format!("/api/signals/{sid}/lift"), None).await; + assert_eq!(s, StatusCode::FORBIDDEN); + let (_, v) = b.call("POST", &format!("/api/signals/{sid}/lift"), None).await; + assert_eq!(v["entered"][0]["code"], "K1"); +} + +#[tokio::test] +async fn inertia_change_is_voted_one_level_up() { + let (mut a, _) = Client::new().await; + a.login("Yvv", false).await; + let id = doc_id(&mut a, "monnaie-libre").await; + let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; + let uid = unit(&doc, "E1")["id"].as_str().expect("u").to_owned(); + let (s, v) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let p = v["id"].as_str().expect("id").to_owned(); + let (_, again) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await; + assert_eq!(again["id"], p.as_str()); + let (_, d) = a.call("GET", &format!("/api/proposals/{p}"), None).await; + assert_eq!(d["proposal"]["votedAtInertia"], "high"); +} + +#[tokio::test] +async fn previews_and_assistant() { + let (mut a, _) = Client::new().await; + a.login("Yvv", false).await; + let id = doc_id(&mut a, "monnaie-libre").await; + let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await; + assert_eq!(v["units"].as_array().expect("u").len(), 33); + let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=wellrated"), None).await; + assert_eq!(v["units"].as_array().expect("u").len(), 3, "nothing is well rated at zero votes"); + let (_, v) = a + .call("POST", &format!("/api/docs/{id}/assist"), Some(json!({ + "text": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.\n\nJe m'engage à rester joignable par mes certifieurs.", + }))) + .await; + let kinds: Vec<&str> = v["hints"].as_array().expect("h").iter().filter_map(|h| h["kind"].as_str()).collect(); + assert!(kinds.contains(&"split"), "{v}"); +} + +#[tokio::test] +async fn split_a_draft() { + let (mut a, _) = Client::new().await; + a.login("Yvv", false).await; + let id = doc_id(&mut a, "essai").await; + let (_, v) = a + .call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ "title": "Deux choses", "body": "a\n\nb" }))) + .await; + let p = v["id"].as_str().expect("id").to_owned(); + let (s, v) = + a.call("POST", &format!("/api/proposals/{p}/split"), Some(json!({ "parts": ["Première.", "Seconde."] }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["drafts"].as_array().expect("d").len(), 2); + let (s, _) = a.call("POST", &format!("/api/proposals/{p}/submit"), None).await; + assert_eq!(s, StatusCode::OK); +} + +#[tokio::test] +async fn access_management_is_for_administrators_only() { + let (mut user, _) = Client::new().await; + user.login("Chiara", false).await; + let (s, _) = user.call("GET", "/api/access", None).await; + assert_eq!(s, StatusCode::FORBIDDEN); + let mut admin = user.with(None); + admin.login("Yvv", true).await; + let (s, v) = + admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Chiara", "groups": ["essai"] }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let link = v["link"].as_str().expect("link").to_owned(); + let token = link.split('#').nth(1).expect("token").to_owned(); + let mut invited = user.with(None); + let (s, v) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["me"]["kind"], "invite"); + let (_, g) = invited.call("GET", "/api/groups", None).await; + let open: Vec<&str> = g["groups"] + .as_array() + .expect("g") + .iter() + .filter(|g| g["open"] == true) + .filter_map(|g| g["slug"].as_str()) + .collect(); + assert_eq!(open, ["essai"]); + // Single use. + let mut again = user.with(None); + let (s, _) = again.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + // Monnaie libre is seen but closed to an invited identity. + let (s, _) = invited.call("GET", "/api/groups/axiom", None).await; + assert_eq!(s, StatusCode::OK); + let (_, ov) = admin.call("GET", "/api/access", None).await; + assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara")); +} + +#[tokio::test] +async fn feedback_reaches_administrators() { + let (mut user, _) = Client::new().await; + user.login("Chiara", false).await; + let (s, _) = user + .call("POST", "/api/feedback", Some(json!({ "route": "/g/essai", "body": "Le bouton est trop discret." }))) + .await; + assert_eq!(s, StatusCode::OK); + let mut admin = user.with(None); + admin.login("Yvv", true).await; + let (_, v) = admin.call("GET", "/api/access/feedback", None).await; + assert_eq!(v["feedback"][0]["body"], "Le bouton est trop discret."); +} diff --git a/crates/trustin-access/Cargo.toml b/crates/trustin-access/Cargo.toml new file mode 100644 index 0000000..ce2c692 --- /dev/null +++ b/crates/trustin-access/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "trustin-access" +description = "Access core of trustinWallet as libreDecision uses it today: identities, groups, who sees and who opens what." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } + +[lints] +workspace = true diff --git a/crates/trustin-access/src/lib.rs b/crates/trustin-access/src/lib.rs new file mode 100644 index 0000000..47a63c1 --- /dev/null +++ b/crates/trustin-access/src/lib.rs @@ -0,0 +1,193 @@ +//! trustin-access — the access core of trustinWallet, as libreDecision uses it +//! today. +//! +//! trustinWallet (formerly trustWallet, a name already taken) is the identity +//! and rights layer of the ecosystem. Until it runs as its own service, this +//! crate holds its rules so that libreDecision never decides access by itself: +//! the server asks `sees` / `opens` / `manages_access`, and stores grants. +//! +//! Rules (Yvv, 2026-10-21): +//! - anyone signed in with a Ğ1 key sees the groups that use the same key — +//! Monnaie libre, Axiom, Librodrome, the local group Vallée Drôme Crestois; +//! - a group opens only to its members; Monnaie libre's members are the +//! members of the Ğ1 web of trust, the others are granted one by one; +//! - everything is open to a member except access management, which belongs +//! to trustinWallet's administrators. + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum IdentityKind { + /// Signed in through connect.monnaie-libre.fr: a Ğ1 identity (`idty:`). + G1, + /// Signed in with an invitation link (tests, before Ğ1 sign-in is wired). + Invite, + /// Local development only. + Dev, +} + +impl IdentityKind { + pub fn as_str(self) -> &'static str { + match self { + Self::G1 => "g1", + Self::Invite => "invite", + Self::Dev => "dev", + } + } + + pub fn parse(raw: &str) -> Option { + [Self::G1, Self::Invite, Self::Dev].into_iter().find(|k| k.as_str() == raw) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Identity { + pub id: String, + pub kind: IdentityKind, + pub display_name: String, + /// Member of the Ğ1 web of trust at the last sign-in. + pub g1_member: bool, + /// Administrator of access (trustinWallet). + pub admin: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum GroupPolicy { + /// Open to every member of the Ğ1 web of trust, plus explicit grants. + G1Members, + /// Open to explicit grants only. + Managed, +} + +impl GroupPolicy { + pub fn as_str(self) -> &'static str { + match self { + Self::G1Members => "g1-members", + Self::Managed => "managed", + } + } + + pub fn parse(raw: &str) -> Option { + [Self::G1Members, Self::Managed].into_iter().find(|p| p.as_str() == raw) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Group { + pub slug: String, + pub name: String, + pub parent: Option, + pub policy: GroupPolicy, + /// Hidden groups are seen by their members only (the test group). + pub hidden: bool, +} + +/// Does this identity belong to the group? `granted` = an explicit grant exists. +pub fn is_member(identity: &Identity, group: &Group, granted: bool) -> bool { + granted || (group.policy == GroupPolicy::G1Members && identity.kind == IdentityKind::G1 && identity.g1_member) +} + +/// Is the group listed for this identity? +pub fn sees(identity: &Identity, group: &Group, granted: bool) -> bool { + !group.hidden || is_member(identity, group, granted) || identity.admin +} + +/// Can this identity open the group and use its tools (read, propose, vote)? +pub fn opens(identity: &Identity, group: &Group, granted: bool) -> bool { + is_member(identity, group, granted) +} + +/// Access management stays with trustinWallet's administrators. +pub fn manages_access(identity: &Identity) -> bool { + identity.admin +} + +/// The groups a fresh deployment starts with. Ordered as displayed. +pub fn default_groups() -> Vec { + let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group { + slug: slug.into(), + name: name.into(), + parent: parent.map(Into::into), + policy, + hidden, + }; + vec![ + g("monnaie-libre", "Monnaie libre", None, GroupPolicy::G1Members, false), + g("axiom", "Axiom", Some("monnaie-libre"), GroupPolicy::Managed, false), + g("librodrome", "Librodrome", Some("monnaie-libre"), GroupPolicy::Managed, false), + g( + "vallee-drome-crestois", + "Groupe local Vallée Drôme Crestois", + Some("monnaie-libre"), + GroupPolicy::Managed, + false, + ), + g("essai", "Groupe d'essai", None, GroupPolicy::Managed, true), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn who(kind: IdentityKind, g1_member: bool, admin: bool) -> Identity { + Identity { id: "x".into(), kind, display_name: "x".into(), g1_member, admin } + } + + fn group(slug: &str) -> Group { + default_groups().into_iter().find(|g| g.slug == slug).expect("default group") + } + + #[test] + fn a_wot_member_sees_all_four_and_opens_monnaie_libre_only() { + let member = who(IdentityKind::G1, true, false); + for slug in ["monnaie-libre", "axiom", "librodrome", "vallee-drome-crestois"] { + assert!(sees(&member, &group(slug), false), "{slug}"); + } + assert!(opens(&member, &group("monnaie-libre"), false)); + assert!(!opens(&member, &group("axiom"), false)); + assert!(opens(&member, &group("axiom"), true)); + assert!(!manages_access(&member)); + } + + #[test] + fn the_test_group_is_hidden_from_non_members() { + let member = who(IdentityKind::G1, true, false); + assert!(!sees(&member, &group("essai"), false)); + assert!(sees(&member, &group("essai"), true)); + } + + #[test] + fn a_non_member_key_or_an_invitation_does_not_open_monnaie_libre_by_itself() { + let former = who(IdentityKind::G1, false, false); + assert!(!opens(&former, &group("monnaie-libre"), false)); + let invited = who(IdentityKind::Invite, false, false); + assert!(!opens(&invited, &group("monnaie-libre"), false)); + assert!(opens(&invited, &group("monnaie-libre"), true)); + } + + #[test] + fn administrators_manage_access_and_see_hidden_groups_but_open_nothing_by_right() { + let admin = who(IdentityKind::Invite, false, true); + assert!(manages_access(&admin)); + assert!(sees(&admin, &group("essai"), false)); + assert!(!opens(&admin, &group("essai"), false)); + } + + #[test] + fn no_atelier_du_canal() { + assert!(default_groups().iter().all(|g| !g.slug.contains("atelier"))); + } + + #[test] + fn round_trips() { + for k in [IdentityKind::G1, IdentityKind::Invite, IdentityKind::Dev] { + assert_eq!(IdentityKind::parse(k.as_str()), Some(k)); + } + for p in [GroupPolicy::G1Members, GroupPolicy::Managed] { + assert_eq!(GroupPolicy::parse(p.as_str()), Some(p)); + } + } +} diff --git a/research_duniter_forum.md b/docs/recherche/forum-duniter-2026-02.md similarity index 100% rename from research_duniter_forum.md rename to docs/recherche/forum-duniter-2026-02.md diff --git a/backend/alembic.ini b/maquette/backend/alembic.ini similarity index 100% rename from backend/alembic.ini rename to maquette/backend/alembic.ini diff --git a/backend/alembic/env.py b/maquette/backend/alembic/env.py similarity index 100% rename from backend/alembic/env.py rename to maquette/backend/alembic/env.py diff --git a/backend/alembic/script.py.mako b/maquette/backend/alembic/script.py.mako similarity index 100% rename from backend/alembic/script.py.mako rename to maquette/backend/alembic/script.py.mako diff --git a/backend/alembic/versions/2026_04_23_0000-0b9c1d2e3f4a_initial_schema.py b/maquette/backend/alembic/versions/2026_04_23_0000-0b9c1d2e3f4a_initial_schema.py similarity index 100% rename from backend/alembic/versions/2026_04_23_0000-0b9c1d2e3f4a_initial_schema.py rename to maquette/backend/alembic/versions/2026_04_23_0000-0b9c1d2e3f4a_initial_schema.py diff --git a/backend/alembic/versions/2026_04_23_1227-70914b334cfb_add_organizations.py b/maquette/backend/alembic/versions/2026_04_23_1227-70914b334cfb_add_organizations.py similarity index 100% rename from backend/alembic/versions/2026_04_23_1227-70914b334cfb_add_organizations.py rename to maquette/backend/alembic/versions/2026_04_23_1227-70914b334cfb_add_organizations.py diff --git a/backend/alembic/versions/2026_04_23_1708-b78571ae9e00_add_qualification_protocol.py b/maquette/backend/alembic/versions/2026_04_23_1708-b78571ae9e00_add_qualification_protocol.py similarity index 100% rename from backend/alembic/versions/2026_04_23_1708-b78571ae9e00_add_qualification_protocol.py rename to maquette/backend/alembic/versions/2026_04_23_1708-b78571ae9e00_add_qualification_protocol.py diff --git a/backend/alembic/versions/2026_04_23_1900-c4e812fb3a01_add_groups.py b/maquette/backend/alembic/versions/2026_04_23_1900-c4e812fb3a01_add_groups.py similarity index 100% rename from backend/alembic/versions/2026_04_23_1900-c4e812fb3a01_add_groups.py rename to maquette/backend/alembic/versions/2026_04_23_1900-c4e812fb3a01_add_groups.py diff --git a/backend/alembic/versions/2026_04_24_1000-d91a3c7f8b02_add_mandate_origin.py b/maquette/backend/alembic/versions/2026_04_24_1000-d91a3c7f8b02_add_mandate_origin.py similarity index 100% rename from backend/alembic/versions/2026_04_24_1000-d91a3c7f8b02_add_mandate_origin.py rename to maquette/backend/alembic/versions/2026_04_24_1000-d91a3c7f8b02_add_mandate_origin.py diff --git a/backend/alembic/versions/2026_04_25_1000-e3f4a5b6c7d8_mandate_origin_id.py b/maquette/backend/alembic/versions/2026_04_25_1000-e3f4a5b6c7d8_mandate_origin_id.py similarity index 100% rename from backend/alembic/versions/2026_04_25_1000-e3f4a5b6c7d8_mandate_origin_id.py rename to maquette/backend/alembic/versions/2026_04_25_1000-e3f4a5b6c7d8_mandate_origin_id.py diff --git a/backend/app/__init__.py b/maquette/backend/app/__init__.py similarity index 100% rename from backend/app/__init__.py rename to maquette/backend/app/__init__.py diff --git a/backend/app/config.py b/maquette/backend/app/config.py similarity index 100% rename from backend/app/config.py rename to maquette/backend/app/config.py diff --git a/backend/app/database.py b/maquette/backend/app/database.py similarity index 100% rename from backend/app/database.py rename to maquette/backend/app/database.py diff --git a/backend/app/dependencies/__init__.py b/maquette/backend/app/dependencies/__init__.py similarity index 100% rename from backend/app/dependencies/__init__.py rename to maquette/backend/app/dependencies/__init__.py diff --git a/backend/app/dependencies/org.py b/maquette/backend/app/dependencies/org.py similarity index 100% rename from backend/app/dependencies/org.py rename to maquette/backend/app/dependencies/org.py diff --git a/backend/app/engine/__init__.py b/maquette/backend/app/engine/__init__.py similarity index 100% rename from backend/app/engine/__init__.py rename to maquette/backend/app/engine/__init__.py diff --git a/backend/app/engine/mode_params.py b/maquette/backend/app/engine/mode_params.py similarity index 100% rename from backend/app/engine/mode_params.py rename to maquette/backend/app/engine/mode_params.py diff --git a/backend/app/engine/nuanced_vote.py b/maquette/backend/app/engine/nuanced_vote.py similarity index 100% rename from backend/app/engine/nuanced_vote.py rename to maquette/backend/app/engine/nuanced_vote.py diff --git a/backend/app/engine/qualifier.py b/maquette/backend/app/engine/qualifier.py similarity index 100% rename from backend/app/engine/qualifier.py rename to maquette/backend/app/engine/qualifier.py diff --git a/backend/app/engine/smith_threshold.py b/maquette/backend/app/engine/smith_threshold.py similarity index 100% rename from backend/app/engine/smith_threshold.py rename to maquette/backend/app/engine/smith_threshold.py diff --git a/backend/app/engine/techcomm_threshold.py b/maquette/backend/app/engine/techcomm_threshold.py similarity index 100% rename from backend/app/engine/techcomm_threshold.py rename to maquette/backend/app/engine/techcomm_threshold.py diff --git a/backend/app/engine/threshold.py b/maquette/backend/app/engine/threshold.py similarity index 100% rename from backend/app/engine/threshold.py rename to maquette/backend/app/engine/threshold.py diff --git a/backend/app/main.py b/maquette/backend/app/main.py similarity index 100% rename from backend/app/main.py rename to maquette/backend/app/main.py diff --git a/backend/app/middleware/__init__.py b/maquette/backend/app/middleware/__init__.py similarity index 100% rename from backend/app/middleware/__init__.py rename to maquette/backend/app/middleware/__init__.py diff --git a/backend/app/middleware/rate_limiter.py b/maquette/backend/app/middleware/rate_limiter.py similarity index 100% rename from backend/app/middleware/rate_limiter.py rename to maquette/backend/app/middleware/rate_limiter.py diff --git a/backend/app/middleware/security_headers.py b/maquette/backend/app/middleware/security_headers.py similarity index 100% rename from backend/app/middleware/security_headers.py rename to maquette/backend/app/middleware/security_headers.py diff --git a/backend/app/models/__init__.py b/maquette/backend/app/models/__init__.py similarity index 100% rename from backend/app/models/__init__.py rename to maquette/backend/app/models/__init__.py diff --git a/backend/app/models/cache.py b/maquette/backend/app/models/cache.py similarity index 100% rename from backend/app/models/cache.py rename to maquette/backend/app/models/cache.py diff --git a/backend/app/models/decision.py b/maquette/backend/app/models/decision.py similarity index 100% rename from backend/app/models/decision.py rename to maquette/backend/app/models/decision.py diff --git a/backend/app/models/document.py b/maquette/backend/app/models/document.py similarity index 100% rename from backend/app/models/document.py rename to maquette/backend/app/models/document.py diff --git a/backend/app/models/group.py b/maquette/backend/app/models/group.py similarity index 100% rename from backend/app/models/group.py rename to maquette/backend/app/models/group.py diff --git a/backend/app/models/mandate.py b/maquette/backend/app/models/mandate.py similarity index 100% rename from backend/app/models/mandate.py rename to maquette/backend/app/models/mandate.py diff --git a/backend/app/models/organization.py b/maquette/backend/app/models/organization.py similarity index 100% rename from backend/app/models/organization.py rename to maquette/backend/app/models/organization.py diff --git a/backend/app/models/protocol.py b/maquette/backend/app/models/protocol.py similarity index 100% rename from backend/app/models/protocol.py rename to maquette/backend/app/models/protocol.py diff --git a/backend/app/models/qualification.py b/maquette/backend/app/models/qualification.py similarity index 100% rename from backend/app/models/qualification.py rename to maquette/backend/app/models/qualification.py diff --git a/backend/app/models/sanctuary.py b/maquette/backend/app/models/sanctuary.py similarity index 100% rename from backend/app/models/sanctuary.py rename to maquette/backend/app/models/sanctuary.py diff --git a/backend/app/models/user.py b/maquette/backend/app/models/user.py similarity index 100% rename from backend/app/models/user.py rename to maquette/backend/app/models/user.py diff --git a/backend/app/models/vote.py b/maquette/backend/app/models/vote.py similarity index 100% rename from backend/app/models/vote.py rename to maquette/backend/app/models/vote.py diff --git a/backend/app/routers/__init__.py b/maquette/backend/app/routers/__init__.py similarity index 100% rename from backend/app/routers/__init__.py rename to maquette/backend/app/routers/__init__.py diff --git a/backend/app/routers/auth.py b/maquette/backend/app/routers/auth.py similarity index 100% rename from backend/app/routers/auth.py rename to maquette/backend/app/routers/auth.py diff --git a/backend/app/routers/decisions.py b/maquette/backend/app/routers/decisions.py similarity index 100% rename from backend/app/routers/decisions.py rename to maquette/backend/app/routers/decisions.py diff --git a/backend/app/routers/documents.py b/maquette/backend/app/routers/documents.py similarity index 100% rename from backend/app/routers/documents.py rename to maquette/backend/app/routers/documents.py diff --git a/backend/app/routers/groups.py b/maquette/backend/app/routers/groups.py similarity index 100% rename from backend/app/routers/groups.py rename to maquette/backend/app/routers/groups.py diff --git a/backend/app/routers/mandates.py b/maquette/backend/app/routers/mandates.py similarity index 100% rename from backend/app/routers/mandates.py rename to maquette/backend/app/routers/mandates.py diff --git a/backend/app/routers/organizations.py b/maquette/backend/app/routers/organizations.py similarity index 100% rename from backend/app/routers/organizations.py rename to maquette/backend/app/routers/organizations.py diff --git a/backend/app/routers/protocols.py b/maquette/backend/app/routers/protocols.py similarity index 100% rename from backend/app/routers/protocols.py rename to maquette/backend/app/routers/protocols.py diff --git a/backend/app/routers/public.py b/maquette/backend/app/routers/public.py similarity index 100% rename from backend/app/routers/public.py rename to maquette/backend/app/routers/public.py diff --git a/backend/app/routers/qualify.py b/maquette/backend/app/routers/qualify.py similarity index 100% rename from backend/app/routers/qualify.py rename to maquette/backend/app/routers/qualify.py diff --git a/backend/app/routers/sanctuary.py b/maquette/backend/app/routers/sanctuary.py similarity index 100% rename from backend/app/routers/sanctuary.py rename to maquette/backend/app/routers/sanctuary.py diff --git a/backend/app/routers/votes.py b/maquette/backend/app/routers/votes.py similarity index 100% rename from backend/app/routers/votes.py rename to maquette/backend/app/routers/votes.py diff --git a/backend/app/routers/websocket.py b/maquette/backend/app/routers/websocket.py similarity index 100% rename from backend/app/routers/websocket.py rename to maquette/backend/app/routers/websocket.py diff --git a/backend/app/schemas/__init__.py b/maquette/backend/app/schemas/__init__.py similarity index 100% rename from backend/app/schemas/__init__.py rename to maquette/backend/app/schemas/__init__.py diff --git a/backend/app/schemas/auth.py b/maquette/backend/app/schemas/auth.py similarity index 100% rename from backend/app/schemas/auth.py rename to maquette/backend/app/schemas/auth.py diff --git a/backend/app/schemas/decision.py b/maquette/backend/app/schemas/decision.py similarity index 100% rename from backend/app/schemas/decision.py rename to maquette/backend/app/schemas/decision.py diff --git a/backend/app/schemas/document.py b/maquette/backend/app/schemas/document.py similarity index 100% rename from backend/app/schemas/document.py rename to maquette/backend/app/schemas/document.py diff --git a/backend/app/schemas/group.py b/maquette/backend/app/schemas/group.py similarity index 100% rename from backend/app/schemas/group.py rename to maquette/backend/app/schemas/group.py diff --git a/backend/app/schemas/mandate.py b/maquette/backend/app/schemas/mandate.py similarity index 100% rename from backend/app/schemas/mandate.py rename to maquette/backend/app/schemas/mandate.py diff --git a/backend/app/schemas/organization.py b/maquette/backend/app/schemas/organization.py similarity index 100% rename from backend/app/schemas/organization.py rename to maquette/backend/app/schemas/organization.py diff --git a/backend/app/schemas/protocol.py b/maquette/backend/app/schemas/protocol.py similarity index 100% rename from backend/app/schemas/protocol.py rename to maquette/backend/app/schemas/protocol.py diff --git a/backend/app/schemas/qualification.py b/maquette/backend/app/schemas/qualification.py similarity index 100% rename from backend/app/schemas/qualification.py rename to maquette/backend/app/schemas/qualification.py diff --git a/backend/app/schemas/sanctuary.py b/maquette/backend/app/schemas/sanctuary.py similarity index 100% rename from backend/app/schemas/sanctuary.py rename to maquette/backend/app/schemas/sanctuary.py diff --git a/backend/app/schemas/vote.py b/maquette/backend/app/schemas/vote.py similarity index 100% rename from backend/app/schemas/vote.py rename to maquette/backend/app/schemas/vote.py diff --git a/backend/app/services/__init__.py b/maquette/backend/app/services/__init__.py similarity index 100% rename from backend/app/services/__init__.py rename to maquette/backend/app/services/__init__.py diff --git a/backend/app/services/auth_service.py b/maquette/backend/app/services/auth_service.py similarity index 100% rename from backend/app/services/auth_service.py rename to maquette/backend/app/services/auth_service.py diff --git a/backend/app/services/blockchain_service.py b/maquette/backend/app/services/blockchain_service.py similarity index 100% rename from backend/app/services/blockchain_service.py rename to maquette/backend/app/services/blockchain_service.py diff --git a/backend/app/services/cache_service.py b/maquette/backend/app/services/cache_service.py similarity index 100% rename from backend/app/services/cache_service.py rename to maquette/backend/app/services/cache_service.py diff --git a/backend/app/services/decision_service.py b/maquette/backend/app/services/decision_service.py similarity index 100% rename from backend/app/services/decision_service.py rename to maquette/backend/app/services/decision_service.py diff --git a/backend/app/services/document_service.py b/maquette/backend/app/services/document_service.py similarity index 100% rename from backend/app/services/document_service.py rename to maquette/backend/app/services/document_service.py diff --git a/backend/app/services/ipfs_service.py b/maquette/backend/app/services/ipfs_service.py similarity index 100% rename from backend/app/services/ipfs_service.py rename to maquette/backend/app/services/ipfs_service.py diff --git a/backend/app/services/mandate_service.py b/maquette/backend/app/services/mandate_service.py similarity index 100% rename from backend/app/services/mandate_service.py rename to maquette/backend/app/services/mandate_service.py diff --git a/backend/app/services/org_service.py b/maquette/backend/app/services/org_service.py similarity index 100% rename from backend/app/services/org_service.py rename to maquette/backend/app/services/org_service.py diff --git a/backend/app/services/qualify_ai_service.py b/maquette/backend/app/services/qualify_ai_service.py similarity index 100% rename from backend/app/services/qualify_ai_service.py rename to maquette/backend/app/services/qualify_ai_service.py diff --git a/backend/app/services/sanctuary_service.py b/maquette/backend/app/services/sanctuary_service.py similarity index 100% rename from backend/app/services/sanctuary_service.py rename to maquette/backend/app/services/sanctuary_service.py diff --git a/backend/app/services/vote_service.py b/maquette/backend/app/services/vote_service.py similarity index 100% rename from backend/app/services/vote_service.py rename to maquette/backend/app/services/vote_service.py diff --git a/backend/app/tests/__init__.py b/maquette/backend/app/tests/__init__.py similarity index 100% rename from backend/app/tests/__init__.py rename to maquette/backend/app/tests/__init__.py diff --git a/backend/app/tests/test_cache_service.py b/maquette/backend/app/tests/test_cache_service.py similarity index 100% rename from backend/app/tests/test_cache_service.py rename to maquette/backend/app/tests/test_cache_service.py diff --git a/backend/app/tests/test_decisions.py b/maquette/backend/app/tests/test_decisions.py similarity index 100% rename from backend/app/tests/test_decisions.py rename to maquette/backend/app/tests/test_decisions.py diff --git a/backend/app/tests/test_doc_protocol_integration.py b/maquette/backend/app/tests/test_doc_protocol_integration.py similarity index 100% rename from backend/app/tests/test_doc_protocol_integration.py rename to maquette/backend/app/tests/test_doc_protocol_integration.py diff --git a/backend/app/tests/test_documents.py b/maquette/backend/app/tests/test_documents.py similarity index 100% rename from backend/app/tests/test_documents.py rename to maquette/backend/app/tests/test_documents.py diff --git a/backend/app/tests/test_mandate_flows.py b/maquette/backend/app/tests/test_mandate_flows.py similarity index 100% rename from backend/app/tests/test_mandate_flows.py rename to maquette/backend/app/tests/test_mandate_flows.py diff --git a/backend/app/tests/test_mandates.py b/maquette/backend/app/tests/test_mandates.py similarity index 100% rename from backend/app/tests/test_mandates.py rename to maquette/backend/app/tests/test_mandates.py diff --git a/backend/app/tests/test_middleware.py b/maquette/backend/app/tests/test_middleware.py similarity index 100% rename from backend/app/tests/test_middleware.py rename to maquette/backend/app/tests/test_middleware.py diff --git a/backend/app/tests/test_mode_params.py b/maquette/backend/app/tests/test_mode_params.py similarity index 100% rename from backend/app/tests/test_mode_params.py rename to maquette/backend/app/tests/test_mode_params.py diff --git a/backend/app/tests/test_nuanced.py b/maquette/backend/app/tests/test_nuanced.py similarity index 100% rename from backend/app/tests/test_nuanced.py rename to maquette/backend/app/tests/test_nuanced.py diff --git a/backend/app/tests/test_public_api.py b/maquette/backend/app/tests/test_public_api.py similarity index 100% rename from backend/app/tests/test_public_api.py rename to maquette/backend/app/tests/test_public_api.py diff --git a/backend/app/tests/test_qualifier.py b/maquette/backend/app/tests/test_qualifier.py similarity index 100% rename from backend/app/tests/test_qualifier.py rename to maquette/backend/app/tests/test_qualifier.py diff --git a/backend/app/tests/test_qualifier_ai.py b/maquette/backend/app/tests/test_qualifier_ai.py similarity index 100% rename from backend/app/tests/test_qualifier_ai.py rename to maquette/backend/app/tests/test_qualifier_ai.py diff --git a/backend/app/tests/test_threshold.py b/maquette/backend/app/tests/test_threshold.py similarity index 100% rename from backend/app/tests/test_threshold.py rename to maquette/backend/app/tests/test_threshold.py diff --git a/backend/app/tests/test_votes.py b/maquette/backend/app/tests/test_votes.py similarity index 100% rename from backend/app/tests/test_votes.py rename to maquette/backend/app/tests/test_votes.py diff --git a/backend/conftest.py b/maquette/backend/conftest.py similarity index 100% rename from backend/conftest.py rename to maquette/backend/conftest.py diff --git a/backend/pytest.ini b/maquette/backend/pytest.ini similarity index 100% rename from backend/pytest.ini rename to maquette/backend/pytest.ini diff --git a/backend/requirements.txt b/maquette/backend/requirements.txt similarity index 100% rename from backend/requirements.txt rename to maquette/backend/requirements.txt diff --git a/backend/scripts/export_seed_bundle.py b/maquette/backend/scripts/export_seed_bundle.py similarity index 100% rename from backend/scripts/export_seed_bundle.py rename to maquette/backend/scripts/export_seed_bundle.py diff --git a/backend/seed.py b/maquette/backend/seed.py similarity index 100% rename from backend/seed.py rename to maquette/backend/seed.py diff --git a/docker/backend.Dockerfile b/maquette/docker/backend.Dockerfile similarity index 100% rename from docker/backend.Dockerfile rename to maquette/docker/backend.Dockerfile diff --git a/docker/docker-compose.dev.yml b/maquette/docker/docker-compose.dev.yml similarity index 100% rename from docker/docker-compose.dev.yml rename to maquette/docker/docker-compose.dev.yml diff --git a/docker/docker-compose.yml b/maquette/docker/docker-compose.yml similarity index 100% rename from docker/docker-compose.yml rename to maquette/docker/docker-compose.yml diff --git a/docker/frontend.Dockerfile b/maquette/docker/frontend.Dockerfile similarity index 100% rename from docker/frontend.Dockerfile rename to maquette/docker/frontend.Dockerfile diff --git a/docs/content/dev/1.index.md b/maquette/docs-v1/dev/1.index.md similarity index 100% rename from docs/content/dev/1.index.md rename to maquette/docs-v1/dev/1.index.md diff --git a/docs/content/dev/10.spike-workflow-engine.md b/maquette/docs-v1/dev/10.spike-workflow-engine.md similarity index 100% rename from docs/content/dev/10.spike-workflow-engine.md rename to maquette/docs-v1/dev/10.spike-workflow-engine.md diff --git a/docs/content/dev/11.governance-modalities.md b/maquette/docs-v1/dev/11.governance-modalities.md similarity index 100% rename from docs/content/dev/11.governance-modalities.md rename to maquette/docs-v1/dev/11.governance-modalities.md diff --git a/docs/content/dev/2.architecture.md b/maquette/docs-v1/dev/2.architecture.md similarity index 100% rename from docs/content/dev/2.architecture.md rename to maquette/docs-v1/dev/2.architecture.md diff --git a/docs/content/dev/3.api-reference.md b/maquette/docs-v1/dev/3.api-reference.md similarity index 100% rename from docs/content/dev/3.api-reference.md rename to maquette/docs-v1/dev/3.api-reference.md diff --git a/docs/content/dev/4.database-schema.md b/maquette/docs-v1/dev/4.database-schema.md similarity index 100% rename from docs/content/dev/4.database-schema.md rename to maquette/docs-v1/dev/4.database-schema.md diff --git a/docs/content/dev/5.formulas.md b/maquette/docs-v1/dev/5.formulas.md similarity index 100% rename from docs/content/dev/5.formulas.md rename to maquette/docs-v1/dev/5.formulas.md diff --git a/docs/content/dev/6.blockchain-integration.md b/maquette/docs-v1/dev/6.blockchain-integration.md similarity index 100% rename from docs/content/dev/6.blockchain-integration.md rename to maquette/docs-v1/dev/6.blockchain-integration.md diff --git a/docs/content/dev/7.contributing.md b/maquette/docs-v1/dev/7.contributing.md similarity index 100% rename from docs/content/dev/7.contributing.md rename to maquette/docs-v1/dev/7.contributing.md diff --git a/docs/content/dev/8.deployment.md b/maquette/docs-v1/dev/8.deployment.md similarity index 100% rename from docs/content/dev/8.deployment.md rename to maquette/docs-v1/dev/8.deployment.md diff --git a/docs/content/dev/9.security.md b/maquette/docs-v1/dev/9.security.md similarity index 100% rename from docs/content/dev/9.security.md rename to maquette/docs-v1/dev/9.security.md diff --git a/docs/content/user/1.index.md b/maquette/docs-v1/user/1.index.md similarity index 100% rename from docs/content/user/1.index.md rename to maquette/docs-v1/user/1.index.md diff --git a/docs/content/user/2.getting-started.md b/maquette/docs-v1/user/2.getting-started.md similarity index 100% rename from docs/content/user/2.getting-started.md rename to maquette/docs-v1/user/2.getting-started.md diff --git a/docs/content/user/3.documents.md b/maquette/docs-v1/user/3.documents.md similarity index 100% rename from docs/content/user/3.documents.md rename to maquette/docs-v1/user/3.documents.md diff --git a/docs/content/user/4.decisions.md b/maquette/docs-v1/user/4.decisions.md similarity index 100% rename from docs/content/user/4.decisions.md rename to maquette/docs-v1/user/4.decisions.md diff --git a/docs/content/user/5.voting.md b/maquette/docs-v1/user/5.voting.md similarity index 100% rename from docs/content/user/5.voting.md rename to maquette/docs-v1/user/5.voting.md diff --git a/docs/content/user/6.mandates.md b/maquette/docs-v1/user/6.mandates.md similarity index 100% rename from docs/content/user/6.mandates.md rename to maquette/docs-v1/user/6.mandates.md diff --git a/docs/content/user/7.sanctuary.md b/maquette/docs-v1/user/7.sanctuary.md similarity index 100% rename from docs/content/user/7.sanctuary.md rename to maquette/docs-v1/user/7.sanctuary.md diff --git a/docs/content/user/8.faq.md b/maquette/docs-v1/user/8.faq.md similarity index 100% rename from docs/content/user/8.faq.md rename to maquette/docs-v1/user/8.faq.md diff --git a/frontend/app/app.config.ts b/maquette/frontend/app/app.config.ts similarity index 100% rename from frontend/app/app.config.ts rename to maquette/frontend/app/app.config.ts diff --git a/frontend/app/app.vue b/maquette/frontend/app/app.vue similarity index 100% rename from frontend/app/app.vue rename to maquette/frontend/app/app.vue diff --git a/frontend/app/assets/css/moods.css b/maquette/frontend/app/assets/css/moods.css similarity index 100% rename from frontend/app/assets/css/moods.css rename to maquette/frontend/app/assets/css/moods.css diff --git a/frontend/app/assets/css/overlays.css b/maquette/frontend/app/assets/css/overlays.css similarity index 100% rename from frontend/app/assets/css/overlays.css rename to maquette/frontend/app/assets/css/overlays.css diff --git a/frontend/app/components/chemin/CheminAlternatives.vue b/maquette/frontend/app/components/chemin/CheminAlternatives.vue similarity index 100% rename from frontend/app/components/chemin/CheminAlternatives.vue rename to maquette/frontend/app/components/chemin/CheminAlternatives.vue diff --git a/frontend/app/components/chemin/CheminChips.vue b/maquette/frontend/app/components/chemin/CheminChips.vue similarity index 100% rename from frontend/app/components/chemin/CheminChips.vue rename to maquette/frontend/app/components/chemin/CheminChips.vue diff --git a/frontend/app/components/chemin/CheminParamEditor.vue b/maquette/frontend/app/components/chemin/CheminParamEditor.vue similarity index 100% rename from frontend/app/components/chemin/CheminParamEditor.vue rename to maquette/frontend/app/components/chemin/CheminParamEditor.vue diff --git a/frontend/app/components/chemin/CheminPathCard.vue b/maquette/frontend/app/components/chemin/CheminPathCard.vue similarity index 100% rename from frontend/app/components/chemin/CheminPathCard.vue rename to maquette/frontend/app/components/chemin/CheminPathCard.vue diff --git a/frontend/app/components/chemin/CheminQ0.vue b/maquette/frontend/app/components/chemin/CheminQ0.vue similarity index 100% rename from frontend/app/components/chemin/CheminQ0.vue rename to maquette/frontend/app/components/chemin/CheminQ0.vue diff --git a/frontend/app/components/chemin/CheminScope.vue b/maquette/frontend/app/components/chemin/CheminScope.vue similarity index 100% rename from frontend/app/components/chemin/CheminScope.vue rename to maquette/frontend/app/components/chemin/CheminScope.vue diff --git a/frontend/app/components/chemin/CheminSuggestion.vue b/maquette/frontend/app/components/chemin/CheminSuggestion.vue similarity index 100% rename from frontend/app/components/chemin/CheminSuggestion.vue rename to maquette/frontend/app/components/chemin/CheminSuggestion.vue diff --git a/frontend/app/components/common/LdAvatarStack.vue b/maquette/frontend/app/components/common/LdAvatarStack.vue similarity index 100% rename from frontend/app/components/common/LdAvatarStack.vue rename to maquette/frontend/app/components/common/LdAvatarStack.vue diff --git a/frontend/app/components/common/LdCountdown.vue b/maquette/frontend/app/components/common/LdCountdown.vue similarity index 100% rename from frontend/app/components/common/LdCountdown.vue rename to maquette/frontend/app/components/common/LdCountdown.vue diff --git a/frontend/app/components/common/LdMoodSwitcher.vue b/maquette/frontend/app/components/common/LdMoodSwitcher.vue similarity index 100% rename from frontend/app/components/common/LdMoodSwitcher.vue rename to maquette/frontend/app/components/common/LdMoodSwitcher.vue diff --git a/frontend/app/components/common/LdSeal.vue b/maquette/frontend/app/components/common/LdSeal.vue similarity index 100% rename from frontend/app/components/common/LdSeal.vue rename to maquette/frontend/app/components/common/LdSeal.vue diff --git a/frontend/app/components/common/LdWorkspaceSelector.vue b/maquette/frontend/app/components/common/LdWorkspaceSelector.vue similarity index 100% rename from frontend/app/components/common/LdWorkspaceSelector.vue rename to maquette/frontend/app/components/common/LdWorkspaceSelector.vue diff --git a/frontend/app/components/common/MarkdownRenderer.vue b/maquette/frontend/app/components/common/MarkdownRenderer.vue similarity index 100% rename from frontend/app/components/common/MarkdownRenderer.vue rename to maquette/frontend/app/components/common/MarkdownRenderer.vue diff --git a/frontend/app/components/decisions/DecisionChain.vue b/maquette/frontend/app/components/decisions/DecisionChain.vue similarity index 100% rename from frontend/app/components/decisions/DecisionChain.vue rename to maquette/frontend/app/components/decisions/DecisionChain.vue diff --git a/frontend/app/components/decisions/DecisionElements.vue b/maquette/frontend/app/components/decisions/DecisionElements.vue similarity index 100% rename from frontend/app/components/decisions/DecisionElements.vue rename to maquette/frontend/app/components/decisions/DecisionElements.vue diff --git a/frontend/app/components/decisions/DecisionInstruct.vue b/maquette/frontend/app/components/decisions/DecisionInstruct.vue similarity index 100% rename from frontend/app/components/decisions/DecisionInstruct.vue rename to maquette/frontend/app/components/decisions/DecisionInstruct.vue diff --git a/frontend/app/components/decisions/DecisionPerimeter.vue b/maquette/frontend/app/components/decisions/DecisionPerimeter.vue similarity index 100% rename from frontend/app/components/decisions/DecisionPerimeter.vue rename to maquette/frontend/app/components/decisions/DecisionPerimeter.vue diff --git a/frontend/app/components/decisions/DecisionProof.vue b/maquette/frontend/app/components/decisions/DecisionProof.vue similarity index 100% rename from frontend/app/components/decisions/DecisionProof.vue rename to maquette/frontend/app/components/decisions/DecisionProof.vue diff --git a/frontend/app/components/decisions/DecisionRegistryCard.vue b/maquette/frontend/app/components/decisions/DecisionRegistryCard.vue similarity index 100% rename from frontend/app/components/decisions/DecisionRegistryCard.vue rename to maquette/frontend/app/components/decisions/DecisionRegistryCard.vue diff --git a/frontend/app/components/decisions/DecisionRegistryFilters.vue b/maquette/frontend/app/components/decisions/DecisionRegistryFilters.vue similarity index 100% rename from frontend/app/components/decisions/DecisionRegistryFilters.vue rename to maquette/frontend/app/components/decisions/DecisionRegistryFilters.vue diff --git a/frontend/app/components/decisions/DecisionSession.vue b/maquette/frontend/app/components/decisions/DecisionSession.vue similarity index 100% rename from frontend/app/components/decisions/DecisionSession.vue rename to maquette/frontend/app/components/decisions/DecisionSession.vue diff --git a/frontend/app/components/decisions/DecisionTimeline.vue b/maquette/frontend/app/components/decisions/DecisionTimeline.vue similarity index 100% rename from frontend/app/components/decisions/DecisionTimeline.vue rename to maquette/frontend/app/components/decisions/DecisionTimeline.vue diff --git a/frontend/app/components/decisions/DecisionVigor.vue b/maquette/frontend/app/components/decisions/DecisionVigor.vue similarity index 100% rename from frontend/app/components/decisions/DecisionVigor.vue rename to maquette/frontend/app/components/decisions/DecisionVigor.vue diff --git a/frontend/app/components/decisions/DecisionWindow.vue b/maquette/frontend/app/components/decisions/DecisionWindow.vue similarity index 100% rename from frontend/app/components/decisions/DecisionWindow.vue rename to maquette/frontend/app/components/decisions/DecisionWindow.vue diff --git a/frontend/app/components/decisions/decisionUi.ts b/maquette/frontend/app/components/decisions/decisionUi.ts similarity index 100% rename from frontend/app/components/decisions/decisionUi.ts rename to maquette/frontend/app/components/decisions/decisionUi.ts diff --git a/frontend/app/components/feed/FeedActivityCard.vue b/maquette/frontend/app/components/feed/FeedActivityCard.vue similarity index 100% rename from frontend/app/components/feed/FeedActivityCard.vue rename to maquette/frontend/app/components/feed/FeedActivityCard.vue diff --git a/frontend/app/components/feed/FeedAdviceCard.vue b/maquette/frontend/app/components/feed/FeedAdviceCard.vue similarity index 100% rename from frontend/app/components/feed/FeedAdviceCard.vue rename to maquette/frontend/app/components/feed/FeedAdviceCard.vue diff --git a/frontend/app/components/feed/FeedCapture.vue b/maquette/frontend/app/components/feed/FeedCapture.vue similarity index 100% rename from frontend/app/components/feed/FeedCapture.vue rename to maquette/frontend/app/components/feed/FeedCapture.vue diff --git a/frontend/app/components/feed/FeedItemCard.vue b/maquette/frontend/app/components/feed/FeedItemCard.vue similarity index 100% rename from frontend/app/components/feed/FeedItemCard.vue rename to maquette/frontend/app/components/feed/FeedItemCard.vue diff --git a/frontend/app/components/feed/FeedReviewCard.vue b/maquette/frontend/app/components/feed/FeedReviewCard.vue similarity index 100% rename from frontend/app/components/feed/FeedReviewCard.vue rename to maquette/frontend/app/components/feed/FeedReviewCard.vue diff --git a/frontend/app/components/feed/FeedSection.vue b/maquette/frontend/app/components/feed/FeedSection.vue similarity index 100% rename from frontend/app/components/feed/FeedSection.vue rename to maquette/frontend/app/components/feed/FeedSection.vue diff --git a/frontend/app/components/feed/FeedVoteCard.vue b/maquette/frontend/app/components/feed/FeedVoteCard.vue similarity index 100% rename from frontend/app/components/feed/FeedVoteCard.vue rename to maquette/frontend/app/components/feed/FeedVoteCard.vue diff --git a/frontend/app/components/feed/FeedWindowCard.vue b/maquette/frontend/app/components/feed/FeedWindowCard.vue similarity index 100% rename from frontend/app/components/feed/FeedWindowCard.vue rename to maquette/frontend/app/components/feed/FeedWindowCard.vue diff --git a/frontend/app/components/mandates/MandateCard.vue b/maquette/frontend/app/components/mandates/MandateCard.vue similarity index 100% rename from frontend/app/components/mandates/MandateCard.vue rename to maquette/frontend/app/components/mandates/MandateCard.vue diff --git a/frontend/app/components/mandates/MandateExercise.vue b/maquette/frontend/app/components/mandates/MandateExercise.vue similarity index 100% rename from frontend/app/components/mandates/MandateExercise.vue rename to maquette/frontend/app/components/mandates/MandateExercise.vue diff --git a/frontend/app/components/mandates/MandateNominationPicker.vue b/maquette/frontend/app/components/mandates/MandateNominationPicker.vue similarity index 100% rename from frontend/app/components/mandates/MandateNominationPicker.vue rename to maquette/frontend/app/components/mandates/MandateNominationPicker.vue diff --git a/frontend/app/components/mandates/MandatePeriodBar.vue b/maquette/frontend/app/components/mandates/MandatePeriodBar.vue similarity index 100% rename from frontend/app/components/mandates/MandatePeriodBar.vue rename to maquette/frontend/app/components/mandates/MandatePeriodBar.vue diff --git a/frontend/app/components/mandates/MandateSpotlights.vue b/maquette/frontend/app/components/mandates/MandateSpotlights.vue similarity index 100% rename from frontend/app/components/mandates/MandateSpotlights.vue rename to maquette/frontend/app/components/mandates/MandateSpotlights.vue diff --git a/frontend/app/components/mandates/ObservatoryBars.vue b/maquette/frontend/app/components/mandates/ObservatoryBars.vue similarity index 100% rename from frontend/app/components/mandates/ObservatoryBars.vue rename to maquette/frontend/app/components/mandates/ObservatoryBars.vue diff --git a/frontend/app/components/mandates/ObservatorySection.vue b/maquette/frontend/app/components/mandates/ObservatorySection.vue similarity index 100% rename from frontend/app/components/mandates/ObservatorySection.vue rename to maquette/frontend/app/components/mandates/ObservatorySection.vue diff --git a/frontend/app/components/mandates/ObservatoryStat.vue b/maquette/frontend/app/components/mandates/ObservatoryStat.vue similarity index 100% rename from frontend/app/components/mandates/ObservatoryStat.vue rename to maquette/frontend/app/components/mandates/ObservatoryStat.vue diff --git a/frontend/app/components/mandates/mandateUi.ts b/maquette/frontend/app/components/mandates/mandateUi.ts similarity index 100% rename from frontend/app/components/mandates/mandateUi.ts rename to maquette/frontend/app/components/mandates/mandateUi.ts diff --git a/frontend/app/components/onboarding/DataCollectivesCard.vue b/maquette/frontend/app/components/onboarding/DataCollectivesCard.vue similarity index 100% rename from frontend/app/components/onboarding/DataCollectivesCard.vue rename to maquette/frontend/app/components/onboarding/DataCollectivesCard.vue diff --git a/frontend/app/components/onboarding/DataIdentityCard.vue b/maquette/frontend/app/components/onboarding/DataIdentityCard.vue similarity index 100% rename from frontend/app/components/onboarding/DataIdentityCard.vue rename to maquette/frontend/app/components/onboarding/DataIdentityCard.vue diff --git a/frontend/app/components/onboarding/DataImportCard.vue b/maquette/frontend/app/components/onboarding/DataImportCard.vue similarity index 100% rename from frontend/app/components/onboarding/DataImportCard.vue rename to maquette/frontend/app/components/onboarding/DataImportCard.vue diff --git a/frontend/app/components/onboarding/OnboardingIdentity.vue b/maquette/frontend/app/components/onboarding/OnboardingIdentity.vue similarity index 100% rename from frontend/app/components/onboarding/OnboardingIdentity.vue rename to maquette/frontend/app/components/onboarding/OnboardingIdentity.vue diff --git a/frontend/app/components/onboarding/OnboardingMembers.vue b/maquette/frontend/app/components/onboarding/OnboardingMembers.vue similarity index 100% rename from frontend/app/components/onboarding/OnboardingMembers.vue rename to maquette/frontend/app/components/onboarding/OnboardingMembers.vue diff --git a/frontend/app/components/onboarding/OnboardingTemplates.vue b/maquette/frontend/app/components/onboarding/OnboardingTemplates.vue similarity index 100% rename from frontend/app/components/onboarding/OnboardingTemplates.vue rename to maquette/frontend/app/components/onboarding/OnboardingTemplates.vue diff --git a/frontend/app/components/texts/AtelierFormulaLab.vue b/maquette/frontend/app/components/texts/AtelierFormulaLab.vue similarity index 100% rename from frontend/app/components/texts/AtelierFormulaLab.vue rename to maquette/frontend/app/components/texts/AtelierFormulaLab.vue diff --git a/frontend/app/components/texts/AtelierMedianDemo.vue b/maquette/frontend/app/components/texts/AtelierMedianDemo.vue similarity index 100% rename from frontend/app/components/texts/AtelierMedianDemo.vue rename to maquette/frontend/app/components/texts/AtelierMedianDemo.vue diff --git a/frontend/app/components/texts/ClauseCard.vue b/maquette/frontend/app/components/texts/ClauseCard.vue similarity index 100% rename from frontend/app/components/texts/ClauseCard.vue rename to maquette/frontend/app/components/texts/ClauseCard.vue diff --git a/frontend/app/components/texts/ClauseDetail.vue b/maquette/frontend/app/components/texts/ClauseDetail.vue similarity index 100% rename from frontend/app/components/texts/ClauseDetail.vue rename to maquette/frontend/app/components/texts/ClauseDetail.vue diff --git a/frontend/app/components/texts/ClauseVersionDiff.vue b/maquette/frontend/app/components/texts/ClauseVersionDiff.vue similarity index 100% rename from frontend/app/components/texts/ClauseVersionDiff.vue rename to maquette/frontend/app/components/texts/ClauseVersionDiff.vue diff --git a/frontend/app/components/texts/InertiaBadge.vue b/maquette/frontend/app/components/texts/InertiaBadge.vue similarity index 100% rename from frontend/app/components/texts/InertiaBadge.vue rename to maquette/frontend/app/components/texts/InertiaBadge.vue diff --git a/frontend/app/components/texts/SessionMiniGauge.vue b/maquette/frontend/app/components/texts/SessionMiniGauge.vue similarity index 100% rename from frontend/app/components/texts/SessionMiniGauge.vue rename to maquette/frontend/app/components/texts/SessionMiniGauge.vue diff --git a/frontend/app/components/texts/TextDocCard.vue b/maquette/frontend/app/components/texts/TextDocCard.vue similarity index 100% rename from frontend/app/components/texts/TextDocCard.vue rename to maquette/frontend/app/components/texts/TextDocCard.vue diff --git a/frontend/app/components/texts/TextProvenance.vue b/maquette/frontend/app/components/texts/TextProvenance.vue similarity index 100% rename from frontend/app/components/texts/TextProvenance.vue rename to maquette/frontend/app/components/texts/TextProvenance.vue diff --git a/frontend/app/components/texts/textsModel.ts b/maquette/frontend/app/components/texts/textsModel.ts similarity index 100% rename from frontend/app/components/texts/textsModel.ts rename to maquette/frontend/app/components/texts/textsModel.ts diff --git a/frontend/app/components/votes/VoteBinary.vue b/maquette/frontend/app/components/votes/VoteBinary.vue similarity index 100% rename from frontend/app/components/votes/VoteBinary.vue rename to maquette/frontend/app/components/votes/VoteBinary.vue diff --git a/frontend/app/components/votes/VoteConsent.vue b/maquette/frontend/app/components/votes/VoteConsent.vue similarity index 100% rename from frontend/app/components/votes/VoteConsent.vue rename to maquette/frontend/app/components/votes/VoteConsent.vue diff --git a/frontend/app/components/votes/VoteElection.vue b/maquette/frontend/app/components/votes/VoteElection.vue similarity index 100% rename from frontend/app/components/votes/VoteElection.vue rename to maquette/frontend/app/components/votes/VoteElection.vue diff --git a/frontend/app/components/votes/VoteInstruct.vue b/maquette/frontend/app/components/votes/VoteInstruct.vue similarity index 100% rename from frontend/app/components/votes/VoteInstruct.vue rename to maquette/frontend/app/components/votes/VoteInstruct.vue diff --git a/frontend/app/components/votes/VoteMyHistory.vue b/maquette/frontend/app/components/votes/VoteMyHistory.vue similarity index 100% rename from frontend/app/components/votes/VoteMyHistory.vue rename to maquette/frontend/app/components/votes/VoteMyHistory.vue diff --git a/frontend/app/components/votes/VoteNuanced.vue b/maquette/frontend/app/components/votes/VoteNuanced.vue similarity index 100% rename from frontend/app/components/votes/VoteNuanced.vue rename to maquette/frontend/app/components/votes/VoteNuanced.vue diff --git a/frontend/app/components/votes/VoteParamSlider.vue b/maquette/frontend/app/components/votes/VoteParamSlider.vue similarity index 100% rename from frontend/app/components/votes/VoteParamSlider.vue rename to maquette/frontend/app/components/votes/VoteParamSlider.vue diff --git a/frontend/app/components/votes/VoteParametric.vue b/maquette/frontend/app/components/votes/VoteParametric.vue similarity index 100% rename from frontend/app/components/votes/VoteParametric.vue rename to maquette/frontend/app/components/votes/VoteParametric.vue diff --git a/frontend/app/composables/useFeed.ts b/maquette/frontend/app/composables/useFeed.ts similarity index 100% rename from frontend/app/composables/useFeed.ts rename to maquette/frontend/app/composables/useFeed.ts diff --git a/frontend/app/composables/useMood.ts b/maquette/frontend/app/composables/useMood.ts similarity index 100% rename from frontend/app/composables/useMood.ts rename to maquette/frontend/app/composables/useMood.ts diff --git a/frontend/app/composables/useSearch.ts b/maquette/frontend/app/composables/useSearch.ts similarity index 100% rename from frontend/app/composables/useSearch.ts rename to maquette/frontend/app/composables/useSearch.ts diff --git a/frontend/app/data/persistence.ts b/maquette/frontend/app/data/persistence.ts similarity index 100% rename from frontend/app/data/persistence.ts rename to maquette/frontend/app/data/persistence.ts diff --git a/frontend/app/data/seeds/atelier-du-canal.bundle.json b/maquette/frontend/app/data/seeds/atelier-du-canal.bundle.json similarity index 100% rename from frontend/app/data/seeds/atelier-du-canal.bundle.json rename to maquette/frontend/app/data/seeds/atelier-du-canal.bundle.json diff --git a/frontend/app/data/seeds/duniter-g1.bundle.json b/maquette/frontend/app/data/seeds/duniter-g1.bundle.json similarity index 100% rename from frontend/app/data/seeds/duniter-g1.bundle.json rename to maquette/frontend/app/data/seeds/duniter-g1.bundle.json diff --git a/frontend/app/data/templates.ts b/maquette/frontend/app/data/templates.ts similarity index 100% rename from frontend/app/data/templates.ts rename to maquette/frontend/app/data/templates.ts diff --git a/frontend/app/engine/impact.ts b/maquette/frontend/app/engine/impact.ts similarity index 100% rename from frontend/app/engine/impact.ts rename to maquette/frontend/app/engine/impact.ts diff --git a/frontend/app/engine/index.ts b/maquette/frontend/app/engine/index.ts similarity index 100% rename from frontend/app/engine/index.ts rename to maquette/frontend/app/engine/index.ts diff --git a/frontend/app/engine/modeParams.ts b/maquette/frontend/app/engine/modeParams.ts similarity index 100% rename from frontend/app/engine/modeParams.ts rename to maquette/frontend/app/engine/modeParams.ts diff --git a/frontend/app/engine/nuanced.ts b/maquette/frontend/app/engine/nuanced.ts similarity index 100% rename from frontend/app/engine/nuanced.ts rename to maquette/frontend/app/engine/nuanced.ts diff --git a/frontend/app/engine/parametric.ts b/maquette/frontend/app/engine/parametric.ts similarity index 100% rename from frontend/app/engine/parametric.ts rename to maquette/frontend/app/engine/parametric.ts diff --git a/frontend/app/engine/settings.ts b/maquette/frontend/app/engine/settings.ts similarity index 100% rename from frontend/app/engine/settings.ts rename to maquette/frontend/app/engine/settings.ts diff --git a/frontend/app/engine/state.ts b/maquette/frontend/app/engine/state.ts similarity index 100% rename from frontend/app/engine/state.ts rename to maquette/frontend/app/engine/state.ts diff --git a/frontend/app/engine/threshold.ts b/maquette/frontend/app/engine/threshold.ts similarity index 100% rename from frontend/app/engine/threshold.ts rename to maquette/frontend/app/engine/threshold.ts diff --git a/frontend/app/engine/triage.ts b/maquette/frontend/app/engine/triage.ts similarity index 100% rename from frontend/app/engine/triage.ts rename to maquette/frontend/app/engine/triage.ts diff --git a/frontend/app/layouts/bare.vue b/maquette/frontend/app/layouts/bare.vue similarity index 100% rename from frontend/app/layouts/bare.vue rename to maquette/frontend/app/layouts/bare.vue diff --git a/frontend/app/layouts/default.vue b/maquette/frontend/app/layouts/default.vue similarity index 100% rename from frontend/app/layouts/default.vue rename to maquette/frontend/app/layouts/default.vue diff --git a/frontend/app/lexicon.ts b/maquette/frontend/app/lexicon.ts similarity index 100% rename from frontend/app/lexicon.ts rename to maquette/frontend/app/lexicon.ts diff --git a/frontend/app/pages/creer.vue b/maquette/frontend/app/pages/creer.vue similarity index 100% rename from frontend/app/pages/creer.vue rename to maquette/frontend/app/pages/creer.vue diff --git a/frontend/app/pages/decider.vue b/maquette/frontend/app/pages/decider.vue similarity index 100% rename from frontend/app/pages/decider.vue rename to maquette/frontend/app/pages/decider.vue diff --git a/frontend/app/pages/decisions/[id]/index.vue b/maquette/frontend/app/pages/decisions/[id]/index.vue similarity index 100% rename from frontend/app/pages/decisions/[id]/index.vue rename to maquette/frontend/app/pages/decisions/[id]/index.vue diff --git a/frontend/app/pages/decisions/[id]/vote.vue b/maquette/frontend/app/pages/decisions/[id]/vote.vue similarity index 100% rename from frontend/app/pages/decisions/[id]/vote.vue rename to maquette/frontend/app/pages/decisions/[id]/vote.vue diff --git a/frontend/app/pages/decisions/index.vue b/maquette/frontend/app/pages/decisions/index.vue similarity index 100% rename from frontend/app/pages/decisions/index.vue rename to maquette/frontend/app/pages/decisions/index.vue diff --git a/frontend/app/pages/decisions/observatoire.vue b/maquette/frontend/app/pages/decisions/observatoire.vue similarity index 100% rename from frontend/app/pages/decisions/observatoire.vue rename to maquette/frontend/app/pages/decisions/observatoire.vue diff --git a/frontend/app/pages/donnees.vue b/maquette/frontend/app/pages/donnees.vue similarity index 100% rename from frontend/app/pages/donnees.vue rename to maquette/frontend/app/pages/donnees.vue diff --git a/frontend/app/pages/index.vue b/maquette/frontend/app/pages/index.vue similarity index 100% rename from frontend/app/pages/index.vue rename to maquette/frontend/app/pages/index.vue diff --git a/frontend/app/pages/mandats/[id].vue b/maquette/frontend/app/pages/mandats/[id].vue similarity index 100% rename from frontend/app/pages/mandats/[id].vue rename to maquette/frontend/app/pages/mandats/[id].vue diff --git a/frontend/app/pages/mandats/index.vue b/maquette/frontend/app/pages/mandats/index.vue similarity index 100% rename from frontend/app/pages/mandats/index.vue rename to maquette/frontend/app/pages/mandats/index.vue diff --git a/frontend/app/pages/mandats/nouveau.vue b/maquette/frontend/app/pages/mandats/nouveau.vue similarity index 100% rename from frontend/app/pages/mandats/nouveau.vue rename to maquette/frontend/app/pages/mandats/nouveau.vue diff --git a/frontend/app/pages/textes/[slug].vue b/maquette/frontend/app/pages/textes/[slug].vue similarity index 100% rename from frontend/app/pages/textes/[slug].vue rename to maquette/frontend/app/pages/textes/[slug].vue diff --git a/frontend/app/pages/textes/formules.vue b/maquette/frontend/app/pages/textes/formules.vue similarity index 100% rename from frontend/app/pages/textes/formules.vue rename to maquette/frontend/app/pages/textes/formules.vue diff --git a/frontend/app/pages/textes/index.vue b/maquette/frontend/app/pages/textes/index.vue similarity index 100% rename from frontend/app/pages/textes/index.vue rename to maquette/frontend/app/pages/textes/index.vue diff --git a/frontend/app/stores/collective.ts b/maquette/frontend/app/stores/collective.ts similarity index 100% rename from frontend/app/stores/collective.ts rename to maquette/frontend/app/stores/collective.ts diff --git a/frontend/app/stores/decisions.ts b/maquette/frontend/app/stores/decisions.ts similarity index 100% rename from frontend/app/stores/decisions.ts rename to maquette/frontend/app/stores/decisions.ts diff --git a/frontend/app/types/domain.ts b/maquette/frontend/app/types/domain.ts similarity index 100% rename from frontend/app/types/domain.ts rename to maquette/frontend/app/types/domain.ts diff --git a/frontend/nuxt.config.ts b/maquette/frontend/nuxt.config.ts similarity index 100% rename from frontend/nuxt.config.ts rename to maquette/frontend/nuxt.config.ts diff --git a/frontend/package-lock.json b/maquette/frontend/package-lock.json similarity index 100% rename from frontend/package-lock.json rename to maquette/frontend/package-lock.json diff --git a/frontend/package.json b/maquette/frontend/package.json similarity index 100% rename from frontend/package.json rename to maquette/frontend/package.json diff --git a/frontend/public/hexagram-tsing-flat.svg b/maquette/frontend/public/hexagram-tsing-flat.svg similarity index 100% rename from frontend/public/hexagram-tsing-flat.svg rename to maquette/frontend/public/hexagram-tsing-flat.svg diff --git a/frontend/public/hexagram-tsing.svg b/maquette/frontend/public/hexagram-tsing.svg similarity index 100% rename from frontend/public/hexagram-tsing.svg rename to maquette/frontend/public/hexagram-tsing.svg diff --git a/frontend/tests/engine/election.spec.ts b/maquette/frontend/tests/engine/election.spec.ts similarity index 100% rename from frontend/tests/engine/election.spec.ts rename to maquette/frontend/tests/engine/election.spec.ts diff --git a/frontend/tests/engine/impact.spec.ts b/maquette/frontend/tests/engine/impact.spec.ts similarity index 100% rename from frontend/tests/engine/impact.spec.ts rename to maquette/frontend/tests/engine/impact.spec.ts diff --git a/frontend/tests/engine/modeParams.spec.ts b/maquette/frontend/tests/engine/modeParams.spec.ts similarity index 100% rename from frontend/tests/engine/modeParams.spec.ts rename to maquette/frontend/tests/engine/modeParams.spec.ts diff --git a/frontend/tests/engine/nuanced.spec.ts b/maquette/frontend/tests/engine/nuanced.spec.ts similarity index 100% rename from frontend/tests/engine/nuanced.spec.ts rename to maquette/frontend/tests/engine/nuanced.spec.ts diff --git a/frontend/tests/engine/parametric.spec.ts b/maquette/frontend/tests/engine/parametric.spec.ts similarity index 100% rename from frontend/tests/engine/parametric.spec.ts rename to maquette/frontend/tests/engine/parametric.spec.ts diff --git a/frontend/tests/engine/settings.spec.ts b/maquette/frontend/tests/engine/settings.spec.ts similarity index 100% rename from frontend/tests/engine/settings.spec.ts rename to maquette/frontend/tests/engine/settings.spec.ts diff --git a/frontend/tests/engine/state.spec.ts b/maquette/frontend/tests/engine/state.spec.ts similarity index 100% rename from frontend/tests/engine/state.spec.ts rename to maquette/frontend/tests/engine/state.spec.ts diff --git a/frontend/tests/engine/threshold.spec.ts b/maquette/frontend/tests/engine/threshold.spec.ts similarity index 100% rename from frontend/tests/engine/threshold.spec.ts rename to maquette/frontend/tests/engine/threshold.spec.ts diff --git a/frontend/tests/engine/triage.spec.ts b/maquette/frontend/tests/engine/triage.spec.ts similarity index 100% rename from frontend/tests/engine/triage.spec.ts rename to maquette/frontend/tests/engine/triage.spec.ts diff --git a/frontend/tests/lexicon.spec.ts b/maquette/frontend/tests/lexicon.spec.ts similarity index 100% rename from frontend/tests/lexicon.spec.ts rename to maquette/frontend/tests/lexicon.spec.ts diff --git a/frontend/tests/seeds/atelier-du-canal.spec.ts b/maquette/frontend/tests/seeds/atelier-du-canal.spec.ts similarity index 100% rename from frontend/tests/seeds/atelier-du-canal.spec.ts rename to maquette/frontend/tests/seeds/atelier-du-canal.spec.ts diff --git a/frontend/tests/seeds/duniter-g1.spec.ts b/maquette/frontend/tests/seeds/duniter-g1.spec.ts similarity index 100% rename from frontend/tests/seeds/duniter-g1.spec.ts rename to maquette/frontend/tests/seeds/duniter-g1.spec.ts diff --git a/frontend/tests/stores/decisions.spec.ts b/maquette/frontend/tests/stores/decisions.spec.ts similarity index 100% rename from frontend/tests/stores/decisions.spec.ts rename to maquette/frontend/tests/stores/decisions.spec.ts diff --git a/frontend/tsconfig.json b/maquette/frontend/tsconfig.json similarity index 100% rename from frontend/tsconfig.json rename to maquette/frontend/tsconfig.json diff --git a/frontend/uno.config.ts b/maquette/frontend/uno.config.ts similarity index 100% rename from frontend/uno.config.ts rename to maquette/frontend/uno.config.ts diff --git a/frontend/vitest.config.ts b/maquette/frontend/vitest.config.ts similarity index 100% rename from frontend/vitest.config.ts rename to maquette/frontend/vitest.config.ts diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..f379743 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.99" +components = ["clippy", "rustfmt"] diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..c775577 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 120 +use_small_heuristics = "Max" diff --git a/seeds/acte-engagement-certification.json b/seeds/acte-engagement-certification.json new file mode 100644 index 0000000..76c5663 --- /dev/null +++ b/seeds/acte-engagement-certification.json @@ -0,0 +1,783 @@ +{ + "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote, inertie standard (Yvv, 2026-10-21).", + "slug": "acte-engagement-certification", + "title": { + "fr": "Acte d'engagement Certification", + "en": "Certification Commitment Act", + "es": "Acta de compromiso de Certificación" + }, + "summary": { + "fr": "Ce que chaque membre de la toile de confiance Ğ1 s'engage à respecter quand il certifie. Texte modulaire sous vote permanent : chaque article entre en vigueur dès qu'il atteint son seuil.", + "en": "What every member of the Ğ1 web of trust commits to uphold when certifying. A modular text under permanent vote: each article comes into force as soon as it reaches its threshold.", + "es": "Lo que todo miembro de la red de confianza Ğ1 se compromete a respetar cuando certifica. Texto modular en votación permanente: cada artículo entra en vigor en cuanto alcanza su umbral." + }, + "sourceLang": "fr", + "instances": [ + { + "group": "monnaie-libre", + "voteFloor": 50, + "publishPath": "monnaie-libre/acte-engagement-certification" + }, + { + "group": "essai", + "voteFloor": 1, + "publishPath": null + } + ], + "units": [ + { + "code": "I1", + "section": "introduction", + "position": 1, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "in_force", + "title": { + "fr": "Préambule", + "en": "Preamble", + "es": "Preámbulo" + }, + "body": { + "fr": "Le présent acte d'engagement définit les obligations réciproques des membres de la toile de confiance de la monnaie libre G1. Cet acte est de fait l'unique relation contractuelle de notre toile fiduciaire. Toute Certification doit s'accompagner de latransmission de ce document, dont le certificateur doit s'assurer qu'il a été étudié, compris et accepté par le certifié.", + "en": "This commitment act defines the reciprocal obligations of the members of the web of trust of the G1 libre currency. This act is, de facto, the sole contractual relationship of our fiduciary web. Every Certification must be accompanied by the transmission of this document, and the certifier must make sure that it has been studied, understood and accepted by the certified person.", + "es": "La presente acta de compromiso define las obligaciones recíprocas de los miembros de la red de confianza de la moneda libre G1. Esta acta es, de hecho, la única relación contractual de nuestra red fiduciaria. Toda Certificación debe ir acompañada de la transmisión de este documento, y el certificador debe asegurarse de que la persona certificada lo ha estudiado, comprendido y aceptado." + } + } + ] + }, + { + "code": "I2", + "section": "introduction", + "position": 2, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Les deux garanties réciproques", + "en": "The two reciprocal guarantees", + "es": "Las dos garantías recíprocas" + }, + "body": { + "fr": "La Certification repose sur deux garanties réciproques :\n\n**1.** Derrière une clé publique créatrice de monnaie se trouve un **être humain vivant**.\n\n**2.** Derrière cet être humain se trouve **une seule et unique** clé publique créatrice de monnaie.\n\nLa Certification est un acte technique et fiduciaire, pas un acte d'adhésion morale ou de sympathie.", + "en": "Certification rests on two reciprocal guarantees:\n\n**1.** Behind a money-creating public key there is a **living human being**.\n\n**2.** Behind this human being there is **one single and unique** money-creating public key.\n\nCertification is a technical and fiduciary act, not an act of moral endorsement or of sympathy.", + "es": "La Certificación se basa en dos garantías recíprocas:\n\n**1.** Detrás de una clave pública creadora de moneda hay un **ser humano vivo**.\n\n**2.** Detrás de este ser humano hay **una sola y única** clave pública creadora de moneda.\n\nLa Certificación es un acto técnico y fiduciario, no un acto de adhesión moral ni de simpatía." + } + } + ] + }, + { + "code": "T1", + "section": "fondamental", + "position": 3, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Engagements fondamentaux", + "en": "Fundamental commitments", + "es": "Compromisos fundamentales" + }, + "body": { + "fr": "Les engagements fondamentaux ci-dessous constituent le socle irréductible de l'acte d'engagement. Ils sont pris sur l'honneur par tout membre de la toile de confiance.", + "en": "The fundamental commitments below form the irreducible foundation of the commitment act. They are made on their honour by every member of the web of trust.", + "es": "Los compromisos fundamentales que siguen constituyen la base irreductible del acta de compromiso. Todo miembro de la red de confianza los asume por su honor." + } + } + ] + }, + { + "code": "E1", + "section": "fondamental", + "position": 4, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "in_force", + "title": { + "fr": "Unicité du compte", + "en": "Uniqueness of the account", + "es": "Unicidad de la cuenta" + }, + "body": { + "fr": "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de monnaie G1.", + "en": "I commit on my honour to have, now and ever, no more than one single and unique account co-creating G1 money.", + "es": "Me comprometo por mi honor a no tener, ahora ni nunca, más que una sola y única cuenta cocreadora de moneda G1." + } + } + ] + }, + { + "code": "E2", + "section": "fondamental", + "position": 5, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "in_force", + "title": { + "fr": "Réciprocité", + "en": "Reciprocity", + "es": "Reciprocidad" + }, + "body": { + "fr": "Je m'engage sur l'honneur à ne certifier que des personnes physiques qui respectent scrupuleusement ces deux présents engagements fondamentaux.", + "en": "I commit on my honour to certify only natural persons who scrupulously respect these two fundamental commitments.", + "es": "Me comprometo por mi honor a certificar únicamente a personas físicas que respeten escrupulosamente estos dos compromisos fundamentales." + } + } + ] + }, + { + "code": "T2", + "section": "technique", + "position": 6, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Engagements techniques", + "en": "Technical commitments", + "es": "Compromisos técnicos" + }, + "body": { + "fr": "Les engagements techniques définissent les obligations pratiques et vérifiables du certificateur pour garantir la qualité de la toile de confiance.", + "en": "The technical commitments define the practical and verifiable obligations of the certifier to guarantee the quality of the web of trust.", + "es": "Los compromisos técnicos definen las obligaciones prácticas y verificables del certificador para garantizar la calidad de la red de confianza." + } + } + ] + }, + { + "code": "E3", + "section": "technique", + "position": 7, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Connaissance suffisante", + "en": "Sufficient knowledge", + "es": "Conocimiento suficiente" + }, + "body": { + "fr": "Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique. Connaître suffisamment ne signifie pas « avoir vu » ; c'est assurer à la communauté G1 que je pourrai la contacter facilement et être en mesure de repérer un double-compte ou tout autre problème.", + "en": "I have made sure that I know the person who manages this public key well enough. Knowing well enough does not mean “having seen”; it means assuring the G1 community that I will be able to contact them easily and to detect a duplicate account or any other problem.", + "es": "Me he asegurado de conocer suficientemente a la persona que gestiona esta clave pública. Conocer suficientemente no significa «haber visto»; significa asegurar a la comunidad G1 que podré contactarla fácilmente y estar en condiciones de detectar una cuenta duplicada o cualquier otro problema." + } + }, + { + "label": "0.4.0-draft", + "status": "open", + "title": { + "fr": "Connaissance suffisante", + "en": "Sufficient knowledge", + "es": "Conocimiento suficiente" + }, + "body": { + "fr": "Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique : je sais la recontacter par au moins deux canaux indépendants, je suis en mesure de repérer un double-compte, et je m'engage à signaler tout doute aux experts de la communauté. « Avoir vu » quelqu'un ne suffit pas.", + "en": "I have made sure that I know the person who manages this public key well enough: I can get back in touch with them through at least two independent channels, I am able to detect a duplicate account, and I commit to reporting any doubt to the community experts. “Having seen” someone is not enough.", + "es": "Me he asegurado de conocer suficientemente a la persona que gestiona esta clave pública: puedo volver a contactarla por al menos dos canales independientes, estoy en condiciones de detectar una cuenta duplicada y me comprometo a comunicar cualquier duda a los expertos de la comunidad. «Haber visto» a alguien no basta." + } + } + ] + }, + { + "code": "E4", + "section": "technique", + "position": 8, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Vérification personnelle de la clé", + "en": "Personal verification of the key", + "es": "Verificación personal de la clave" + }, + "body": { + "fr": "J'ai personnellement vérifié que c'est bien cette clé publique que je m'apprête à certifier, en la comparant avec la personne concernée et non par un intermédiaire.", + "en": "I have personally verified that this is indeed the public key I am about to certify, by comparing it with the person concerned and not through an intermediary.", + "es": "He verificado personalmente que esta es efectivamente la clave pública que me dispongo a certificar, comparándola con la persona interesada y no a través de un intermediario." + } + } + ] + }, + { + "code": "E5", + "section": "technique", + "position": 9, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Joignabilité réciproque", + "en": "Mutual reachability", + "es": "Contacto recíproco" + }, + "body": { + "fr": "Je suis joignable rapidement et facilement par mes certifieurs, et je peux joindre rapidement et facilement les personnes que je certifie, par plusieurs moyens de communication différents et indépendants (physique, téléphone, email, messagerie, etc.).", + "en": "I can be reached quickly and easily by my certifiers, and I can quickly and easily reach the people I certify, through several different and independent means of communication (in person, phone, email, messaging, etc.).", + "es": "Soy localizable de forma rápida y sencilla por mis certificadores, y puedo localizar de forma rápida y sencilla a las personas que certifico, por varios medios de comunicación diferentes e independientes (en persona, teléfono, correo electrónico, mensajería, etc.)." + } + } + ] + }, + { + "code": "E6", + "section": "technique", + "position": 10, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Document de révocation", + "en": "Revocation document", + "es": "Documento de revocación" + }, + "body": { + "fr": "J'ai vérifié avec la personne certifiée qu'elle a bien généré son document de révocation de compte et qu'elle le conserve en lieu sûr.", + "en": "I have checked with the certified person that they have indeed generated their account revocation document and that they keep it in a safe place.", + "es": "He verificado con la persona certificada que ha generado efectivamente su documento de revocación de cuenta y que lo conserva en un lugar seguro." + } + } + ] + }, + { + "code": "E7", + "section": "technique", + "position": 11, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Rencontre physique ou vérification multi-canaux", + "en": "In-person meeting or multi-channel verification", + "es": "Encuentro en persona o verificación multicanal" + }, + "body": { + "fr": "J'ai rencontré la personne physiquement (préférable), **OU** j'ai vérifié à distance le lien personne / clé publique par plusieurs moyens de communication différents et indépendants : courrier + réseau social + forum + email + visio + téléphone.", + "en": "I have met the person face to face (preferable), **OR** I have remotely verified the person / public key link through several different and independent means of communication: postal mail + social network + forum + email + video call + phone.", + "es": "Me he encontrado físicamente con la persona (preferible), **O** he verificado a distancia el vínculo persona / clave pública por varios medios de comunicación diferentes e independientes: correo postal + red social + foro + correo electrónico + videollamada + teléfono." + } + }, + { + "label": "0.4.0-draft", + "status": "open", + "title": { + "fr": "Rencontre physique ou vérification multi-canaux", + "en": "In-person meeting or multi-channel verification", + "es": "Encuentro en persona o verificación multicanal" + }, + "body": { + "fr": "J'ai rencontré la personne physiquement (préférable), **OU** j'ai vérifié à distance le lien personne / clé publique par au moins trois moyens de communication différents et indépendants, dont un échange en visio en direct.", + "en": "I have met the person face to face (preferable), **OR** I have remotely verified the person / public key link through at least three different and independent means of communication, including a live video call.", + "es": "Me he encontrado físicamente con la persona (preferible), **O** he verificado a distancia el vínculo persona / clave pública por al menos tres medios de comunicación diferentes e independientes, entre ellos una videollamada en directo." + } + } + ] + }, + { + "code": "T3", + "section": "technique", + "position": 12, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Conseils et bonnes pratiques", + "en": "Advice and good practices", + "es": "Consejos y buenas prácticas" + }, + "body": { + "fr": "Les pratiques suivantes sont fortement recommandées pour garantir la qualité et la sécurité de la toile de confiance.", + "en": "The following practices are strongly recommended to guarantee the quality and security of the web of trust.", + "es": "Las siguientes prácticas se recomiendan encarecidamente para garantizar la calidad y la seguridad de la red de confianza." + } + } + ] + }, + { + "code": "E8", + "section": "technique", + "position": 13, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Ne jamais certifier seul", + "en": "Never certify alone", + "es": "No certificar nunca en solitario" + }, + "body": { + "fr": "Ne certifiez jamais seul, mais accompagné d'au moins un autre membre de la toile de confiance G1, pour garantir un double contrôle.", + "en": "Never certify alone, but accompanied by at least one other member of the G1 web of trust, to guarantee a double check.", + "es": "No certifique nunca en solitario, sino junto con al menos otro miembro de la red de confianza G1, para garantizar un doble control." + } + } + ] + }, + { + "code": "E9", + "section": "technique", + "position": 14, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Vérification des certifications existantes", + "en": "Verification of existing certifications", + "es": "Verificación de las certificaciones existentes" + }, + "body": { + "fr": "Avant toute Certification, vérifiez si le compte a déjà reçu des certifications et de qui elles proviennent. Contactez les certifieurs existants en cas de doute. Si un certifieurs existant ne connaît pas la personne, alertez immédiatement les experts de la communauté.", + "en": "Before any Certification, check whether the account has already received certifications and who they come from. Contact the existing certifiers if in doubt. If an existing certifier does not know the person, alert the community experts immediately.", + "es": "Antes de cualquier Certificación, verifique si la cuenta ya ha recibido certificaciones y de quién proceden. En caso de duda, contacte con los certificadores existentes. Si un certificador existente no conoce a la persona, alerte de inmediato a los expertos de la comunidad." + } + } + ] + }, + { + "code": "E10", + "section": "technique", + "position": 15, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Vérification de maîtrise du compte", + "en": "Verification of account control", + "es": "Verificación del control de la cuenta" + }, + "body": { + "fr": "Vérifiez la maîtrise du compte par un transfert-test : envoyez quelques G1 et demandez le renvoi, afin de vous assurer que la personne contrôle bien sa clé privée.", + "en": "Verify control of the account with a test transfer: send a few G1 and ask for them to be sent back, to make sure that the person really controls their private key.", + "es": "Verifique el control de la cuenta mediante una transferencia de prueba: envíe unos pocos G1 y pida que se los devuelvan, para asegurarse de que la persona controla realmente su clave privada." + } + } + ] + }, + { + "code": "E11", + "section": "technique", + "position": 16, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Transmission et compréhension du document", + "en": "Transmission and understanding of the document", + "es": "Transmisión y comprensión del documento" + }, + "body": { + "fr": "Vérifiez que vos contacts ont bien étudié et compris le présent acte d'engagement dans sa version à jour avant de procéder à la Certification.", + "en": "Check that your contacts have indeed studied and understood the current version of this commitment act before proceeding with the Certification.", + "es": "Verifique que sus contactos han estudiado y comprendido efectivamente la versión vigente de la presente acta de compromiso antes de proceder a la Certificación." + } + } + ] + }, + { + "code": "K1", + "section": "conclusion", + "position": 17, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Règles abrégées de la toile de confiance", + "en": "Abridged rules of the web of trust", + "es": "Reglas abreviadas de la red de confianza" + }, + "body": { + "fr": "**Paramètres protocolaires en vigueur :**\n\n- Stock de **100 certifications** possibles\n- 1 certification émissible tous les **5 jours**\n- Nouveau compte valide si **>= 5 certifications** reçues en **2 mois**\n- Condition de distance : **<= 5 pas** de **80% des sentinelles**\n- Sentinelle : membre ayant reçu et émis >= Y[N] certifs (Y = ceil(N^(1/5)))\n- Certifications actives valables **2 ans**\n- Renouvellement de l'accord tous les **12 mois**\n\n*Note : le vote porte sur l'inclusion de ces règles dans le document, pas sur les valeurs des variables protocolaires elles-mêmes, qui sont fixées par le protocole Duniter.*", + "en": "**Protocol parameters in force:**\n\n- Stock of **100 certifications** available\n- 1 certification can be issued every **5 days**\n- New account valid if **>= 5 certifications** received within **2 months**\n- Distance rule: **<= 5 steps** from **80% of the sentries**\n- Sentry: member who has received and issued >= Y[N] certifications (Y = ceil(N^(1/5)))\n- Active certifications valid for **2 years**\n- Renewal of the agreement every **12 months**\n\n*Note: the vote concerns the inclusion of these rules in the document, not the values of the protocol variables themselves, which are set by the Duniter protocol.*", + "es": "**Parámetros de protocolo vigentes:**\n\n- Reserva de **100 certificaciones** posibles\n- 1 certificación emitible cada **5 días**\n- Cuenta nueva válida si recibe **>= 5 certificaciones** en **2 meses**\n- Regla de distancia: **<= 5 pasos** del **80% de los centinelas**\n- Centinela: miembro que ha recibido y emitido >= Y[N] certificaciones (Y = ceil(N^(1/5)))\n- Certificaciones activas válidas durante **2 años**\n- Renovación del acuerdo cada **12 meses**\n\n*Nota: la votación se refiere a la inclusión de estas reglas en el documento, no a los valores de las variables del protocolo en sí, que fija el protocolo Duniter.*" + } + } + ] + }, + { + "code": "K2", + "section": "conclusion", + "position": 18, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Monnaie G1", + "en": "G1 currency", + "es": "Moneda G1" + }, + "body": { + "fr": "**Paramètres monétaires :**\n\n- 1 Dividende Universel (DU) par personne par jour\n- Réévaluation à chaque équinoxe : `DU(n+1) = DU(n) + c² × (M/N) / 182.625` avec c = 4.88%\n- DU(0) = 10.00 G1\n\n*Note : le vote porte sur l'inclusion de ces paramètres dans le document, pas sur les valeurs monétaires elles-mêmes, qui découlent de la TRM et du bloc 0.*", + "en": "**Monetary parameters:**\n\n- 1 Universal Dividend (UD) per person per day\n- Revaluation at each equinox: `DU(n+1) = DU(n) + c² × (M/N) / 182.625` with c = 4.88%\n- DU(0) = 10.00 G1\n\n*Note: the vote concerns the inclusion of these parameters in the document, not the monetary values themselves, which derive from the RTM and block 0.*", + "es": "**Parámetros monetarios:**\n\n- 1 Dividendo Universal (DU) por persona y día\n- Revalorización en cada equinoccio: `DU(n+1) = DU(n) + c² × (M/N) / 182.625` con c = 4.88%\n- DU(0) = 10.00 G1\n\n*Nota: la votación se refiere a la inclusión de estos parámetros en el documento, no a los valores monetarios en sí, que se derivan de la TRM y del bloque 0.*" + } + } + ] + }, + { + "code": "X1", + "section": "annexe", + "position": 19, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Annexe 1 : Intégration logicielle", + "en": "Annex 1: Software integration", + "es": "Anexo 1: Integración en el software" + }, + "body": { + "fr": "Les obligations pour les logiciels implémentant la Certification G1.", + "en": "The obligations for software implementing G1 Certification.", + "es": "Las obligaciones del software que implementa la Certificación G1." + } + } + ] + }, + { + "code": "X1.1", + "section": "annexe", + "position": 20, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Dépôt de référence", + "en": "Reference repository", + "es": "Repositorio de referencia" + }, + "body": { + "fr": "Le texte de référence de l'acte d'engagement Certification est hébergé dans le dépôt git officiel : https://git.duniter.org/documents/g1_monetary_license\n\nLes applications Cesium, Gecko et toute application de Certification doivent pointer vers ce dépôt pour afficher la version en vigueur.", + "en": "The reference text of the Certification commitment act is hosted in the official git repository: https://git.duniter.org/documents/g1_monetary_license\n\nThe Cesium and Gecko applications, and any Certification application, must point to this repository to display the version in force.", + "es": "El texto de referencia del acta de compromiso de Certificación está alojado en el repositorio git oficial: https://git.duniter.org/documents/g1_monetary_license\n\nLas aplicaciones Cesium, Gecko y cualquier aplicación de Certificación deben apuntar a este repositorio para mostrar la versión vigente." + } + } + ] + }, + { + "code": "X1.2", + "section": "annexe", + "position": 21, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Obligation de transmission", + "en": "Transmission obligation", + "es": "Obligación de transmisión" + }, + "body": { + "fr": "Tout logiciel G1 permettant la Certification doit transmettre le présent acte d'engagement au certifié et en afficher les paramètres du bloc 0 de la blockchain Duniter.", + "en": "Any G1 software that enables Certification must transmit this commitment act to the certified person and display the block 0 parameters of the Duniter blockchain.", + "es": "Todo software G1 que permita la Certificación debe transmitir la presente acta de compromiso a la persona certificada y mostrar los parámetros del bloque 0 de la blockchain Duniter." + } + } + ] + }, + { + "code": "X1.3", + "section": "annexe", + "position": 22, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Logiciel auditable", + "en": "Auditable software", + "es": "Software auditable" + }, + "body": { + "fr": "Tout logiciel utilisé pour la Certification ou la création monétaire doit être publié sous licence libre, afin de permettre son audit par la communauté.", + "en": "Any software used for Certification or money creation must be published under a free licence, so that it can be audited by the community.", + "es": "Todo software utilizado para la Certificación o la creación monetaria debe publicarse bajo licencia libre, para permitir su auditoría por parte de la comunidad." + } + } + ] + }, + { + "code": "X2", + "section": "annexe", + "position": 23, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Annexe 2 : Questions à la Certification", + "en": "Annex 2: Questions for Certification", + "es": "Anexo 2: Preguntas para la Certificación" + }, + "body": { + "fr": "Liste de questions à présenter dans les logiciels de Certification.Inspirée de la checklist de la Charte 1.0 et des discussions forum (topic 32412).", + "en": "List of questions to be presented in Certification software. Inspired by the checklist of Charter 1.0 and by forum discussions (topic 32412).", + "es": "Lista de preguntas que deben presentarse en el software de Certificación. Inspirada en la lista de verificación de la Carta 1.0 y en los debates del foro (tema 32412)." + } + } + ] + }, + { + "code": "X2.1", + "section": "annexe", + "position": 24, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Questions piège (réponse attendue : NON)", + "en": "Trap questions (expected answer: NO)", + "es": "Preguntas trampa (respuesta esperada: NO)" + }, + "body": { + "fr": "**Si OUI à l'une de ces questions, la Certification doit être refusée.**\n\n- La personne m'a contacté uniquement pour obtenir ma Certification\n- Je certifie sous la pression ou pour faire plaisir\n- Je n'ai aucun moyen de vérifier l'identité de la personne\n- La personne refuse de me fournir plusieurs moyens de contact\n- Je soupçonne que la personne possède déjà un compte membre\n- Je ne connais aucun de ses autres certifieurs\n- La personne ne comprend pas ce qu'est la monnaie libre\n- La personne n'a pas lu le présent acte d'engagement", + "en": "**If YES to any of these questions, the Certification must be refused.**\n\n- The person contacted me only to obtain my Certification\n- I am certifying under pressure or to please someone\n- I have no way of verifying the person's identity\n- The person refuses to give me several means of contact\n- I suspect that the person already has a member account\n- I do not know any of their other certifiers\n- The person does not understand what libre currency is\n- The person has not read this commitment act", + "es": "**Si la respuesta es SÍ a alguna de estas preguntas, la Certificación debe rechazarse.**\n\n- La persona me ha contactado únicamente para obtener mi Certificación\n- Certifico bajo presión o por complacer\n- No tengo ningún medio para verificar la identidad de la persona\n- La persona se niega a proporcionarme varios medios de contacto\n- Sospecho que la persona ya posee una cuenta de miembro\n- No conozco a ninguno de sus otros certificadores\n- La persona no entiende qué es la moneda libre\n- La persona no ha leído la presente acta de compromiso" + } + } + ] + }, + { + "code": "X2.2", + "section": "annexe", + "position": 25, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Questions identité (réponse attendue : OUI)", + "en": "Identity questions (expected answer: YES)", + "es": "Preguntas de identidad (respuesta esperada: SÍ)" + }, + "body": { + "fr": "**Si NON à l'une de ces questions, la Certification doit être refusée.**\n\n- Je connais cette personne suffisamment pour la recontacter\n- J'ai vérifié personnellement le lien personne / clé publique\n- La personne est une personne physique vivante (pas une entité morale)\n- Je pourrais reconnaître cette personne si je la croisais\n- J'ai au moins 2 moyens de contact différents pour cette personne", + "en": "**If NO to any of these questions, the Certification must be refused.**\n\n- I know this person well enough to get back in touch with them\n- I have personally verified the person / public key link\n- The person is a living natural person (not a legal entity)\n- I would recognise this person if I came across them\n- I have at least 2 different means of contact for this person", + "es": "**Si la respuesta es NO a alguna de estas preguntas, la Certificación debe rechazarse.**\n\n- Conozco a esta persona suficientemente para volver a contactarla\n- He verificado personalmente el vínculo persona / clave pública\n- La persona es una persona física viva (no una persona jurídica)\n- Reconocería a esta persona si me la cruzara\n- Tengo al menos 2 medios de contacto diferentes para esta persona" + } + } + ] + }, + { + "code": "X2.3", + "section": "annexe", + "position": 26, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Questions sécurité (réponse attendue : OUI)", + "en": "Security questions (expected answer: YES)", + "es": "Preguntas de seguridad (respuesta esperada: SÍ)" + }, + "body": { + "fr": "**Si NON, la Certification est déconseillée.**\n\n- La personne a généré son document de révocation\n- La personne maîtrise effectivement son compte (test de transfert effectué)\n- La personne sait où retrouver le présent acte d'engagement dans sa version à jour", + "en": "**If NO, the Certification is not recommended.**\n\n- The person has generated their revocation document\n- The person actually controls their account (test transfer carried out)\n- The person knows where to find the current version of this commitment act", + "es": "**Si la respuesta es NO, se desaconseja la Certificación.**\n\n- La persona ha generado su documento de revocación\n- La persona controla efectivamente su cuenta (transferencia de prueba realizada)\n- La persona sabe dónde encontrar la versión vigente de la presente acta de compromiso" + } + } + ] + }, + { + "code": "F1", + "section": "formule", + "position": 27, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Formule de vote", + "en": "Voting formula", + "es": "Fórmula de votación" + }, + "body": { + "fr": "La formule qui régit l'adoption ou le rejet de chaque modification du présent document. Référence : g1vote (https://g1vote-view-237903.pages.duniter.org/).", + "en": "The formula that governs the adoption or rejection of each amendment to this document. Reference: g1vote (https://g1vote-view-237903.pages.duniter.org/).", + "es": "La fórmula que rige la adopción o el rechazo de cada modificación del presente documento. Referencia: g1vote (https://g1vote-view-237903.pages.duniter.org/)." + } + } + ] + }, + { + "code": "F1.1", + "section": "formule", + "position": 28, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Formule du seuil WoT", + "en": "WoT threshold formula", + "es": "Fórmula del umbral WoT" + }, + "body": { + "fr": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nOù :\n- **C** = constante de base (plancher fixe)\n- **B** = exposant de base (B^W tend vers 0)\n- **W** = taille de la toile de confiance (électeurs éligibles)\n- **T** = total des votes exprimés (pour + contre)\n- **M** = ratio de majorité cible (M = majority_pct / 100)\n- **G** = exposant du gradient d'inertie\n\n**Comportement :** Faible participation → quasi-unanimité requise. Forte participation → majorité simple M suffit.", + "en": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nWhere:\n- **C** = base constant (fixed floor)\n- **B** = base exponent (B^W tends towards 0)\n- **W** = size of the web of trust (eligible voters)\n- **T** = total votes cast (for + against)\n- **M** = target majority ratio (M = majority_pct / 100)\n- **G** = inertia gradient exponent\n\n**Behaviour:** Low participation → near-unanimity required. High participation → simple majority M is enough.", + "es": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nDonde:\n- **C** = constante de base (mínimo fijo)\n- **B** = exponente de base (B^W tiende a 0)\n- **W** = tamaño de la red de confianza (votantes habilitados)\n- **T** = total de votos emitidos (a favor + en contra)\n- **M** = proporción de mayoría objetivo (M = majority_pct / 100)\n- **G** = exponente del gradiente de inercia\n\n**Comportamiento:** Baja participación → se requiere casi unanimidad. Alta participación → basta la mayoría simple M." + } + } + ] + }, + { + "code": "F1.2", + "section": "formule", + "position": 29, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Paramètres par défaut", + "en": "Default parameters", + "es": "Parámetros por defecto" + }, + "body": { + "fr": "**Seuil = M + (1-M) × (1 - (T/W)^G)**\n\n- **T** = votes exprimés (pour + contre)\n- **W** = taille de la toile de confiance\n- **M** = majorité cible (50% par défaut)\n- **G** = gradient d'inertie (0.2 par défaut)\n\n**Valeurs par défaut (inertie standard) :**\n- Durée : 30 jours (vote permanent)\n- Majorité cible M = 50%\n- Gradient G = 0.2\n- Base B = 0.1, Constante C = 0\n\n**Lecture du curseur d'inertie :**\nLe curseur affiché sur chaque section représente le niveau G. Plus le curseur est à droite, plus le remplacement est difficile. La courbe associée montre le seuil requis en fonction de la participation.\n\nMode compact : **D30M50B.1G.2**", + "en": "**Seuil = M + (1-M) × (1 - (T/W)^G)**\n\n- **T** = votes cast (for + against)\n- **W** = size of the web of trust\n- **M** = target majority (50% by default)\n- **G** = inertia gradient (0.2 by default)\n\n**Default values (standard inertia):**\n- Duration: 30 days (permanent vote)\n- Target majority M = 50%\n- Gradient G = 0.2\n- Base B = 0.1, Constant C = 0\n\n**Reading the inertia slider:**\nThe slider shown on each section represents the level G. The further to the right the slider, the harder the replacement. The associated curve shows the required threshold as a function of participation.\n\nCompact mode: **D30M50B.1G.2**", + "es": "**Seuil = M + (1-M) × (1 - (T/W)^G)**\n\n- **T** = votos emitidos (a favor + en contra)\n- **W** = tamaño de la red de confianza\n- **M** = mayoría objetivo (50% por defecto)\n- **G** = gradiente de inercia (0.2 por defecto)\n\n**Valores por defecto (inercia estándar):**\n- Duración: 30 días (votación permanente)\n- Mayoría objetivo M = 50%\n- Gradiente G = 0.2\n- Base B = 0.1, Constante C = 0\n\n**Lectura del cursor de inercia:**\nEl cursor que se muestra en cada sección representa el nivel G. Cuanto más a la derecha está el cursor, más difícil es la sustitución. La curva asociada muestra el umbral requerido en función de la participación.\n\nModo compacto: **D30M50B.1G.2**" + } + } + ] + }, + { + "code": "F1.3", + "section": "formule", + "position": 30, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Processus de dépôt officiel", + "en": "Official filing process", + "es": "Proceso de depósito oficial" + }, + "body": { + "fr": "Lorsqu'une proposition alternative atteint le seuil d'adoption :\n\n1. Le texte de remplacement est intégré au document officiel\n2. Le hash IPFS du document mis à jour est calculé\n3. Le hash est ancré on-chain via `system.remark`\n4. Le dépôt git officiel est synchronisé\n5. Les applications (Cesium, Gecko) mettent à jour automatiquement", + "en": "When an alternative proposal reaches the adoption threshold:\n\n1. The replacement text is incorporated into the official document\n2. The IPFS hash of the updated document is computed\n3. The hash is anchored on-chain via `system.remark`\n4. The official git repository is synchronised\n5. The applications (Cesium, Gecko) update automatically", + "es": "Cuando una propuesta alternativa alcanza el umbral de adopción:\n\n1. El texto de sustitución se integra en el documento oficial\n2. Se calcula el hash IPFS del documento actualizado\n3. El hash se ancla on-chain mediante `system.remark`\n4. Se sincroniza el repositorio git oficial\n5. Las aplicaciones (Cesium, Gecko) se actualizan automáticamente" + } + } + ] + }, + { + "code": "N1", + "section": "inertie", + "position": 31, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Réglage de l'inertie", + "en": "Inertia setting", + "es": "Ajuste de la inercia" + }, + "body": { + "fr": "Le réglage de l'inertie définit la difficulté de remplacement de chaque section du document. Ce réglage est lui-même soumis à l'inertie la plus élevée, pour empêcher la modification des règles de modification.", + "en": "The inertia setting defines how difficult it is to replace each section of the document. This setting is itself subject to the highest inertia, to prevent the rules of modification from being modified.", + "es": "El ajuste de la inercia define la dificultad de sustitución de cada sección del documento. Este ajuste está a su vez sometido a la inercia más elevada, para impedir la modificación de las reglas de modificación." + } + } + ] + }, + { + "code": "N1.1", + "section": "inertie", + "position": 32, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Niveaux d'inertie", + "en": "Inertia levels", + "es": "Niveles de inercia" + }, + "body": { + "fr": "**Formule simplifiée :** `Seuil = M + (1-M) × (1 - (T/W)^G)`\n\nLe **gradient G** contrôle la courbe d'inertie : plus G est élevé, plus le seuil reste haut même avec une participation croissante.\n\n**Basse** — G=0.1, M=50%\n- La courbe descend très vite vers 50%\n- Dès 10% de participation, le seuil est proche de la majorité simple\n- *Usage : annexes, recommandations, conseils*\n\n**Standard** — G=0.2, M=50%\n- Descente progressive, équilibre entre stabilité et réactivité\n- À 25% de participation, le seuil avoisine 65%\n- *Usage : engagements fondamentaux et techniques*\n\n**Haute** — G=0.4, M=60%\n- Courbe résistante, le seuil reste élevé longtemps\n- Super-majorité de 60% même à pleine participation\n- *Usage : formule de vote elle-même*\n\n**Très haute** — G=0.6, M=66%\n- Quasi-unanimité requise à tout niveau de participation\n- Seul un consensus massif (66%+) peut modifier ce réglage\n- *Usage : réglage de l'inertie (méta-protection)*\n\nLe curseur d'inertie associé à chaque section du document reflète visuellement ces niveaux.", + "en": "**Simplified formula:** `Seuil = M + (1-M) × (1 - (T/W)^G)`\n\nThe **gradient G** controls the inertia curve: the higher G is, the higher the threshold remains, even as participation increases.\n\n**Low** — G=0.1, M=50%\n- The curve drops very quickly towards 50%\n- From 10% participation, the threshold is close to a simple majority\n- *Use: annexes, recommendations, advice*\n\n**Standard** — G=0.2, M=50%\n- Gradual descent, balance between stability and responsiveness\n- At 25% participation, the threshold is around 65%\n- *Use: fundamental and technical commitments*\n\n**High** — G=0.4, M=60%\n- Resistant curve, the threshold stays high for a long time\n- Supermajority of 60% even at full participation\n- *Use: the voting formula itself*\n\n**Very high** — G=0.6, M=66%\n- Near-unanimity required at every level of participation\n- Only a massive consensus (66%+) can modify this setting\n- *Use: the inertia setting (meta-protection)*\n\nThe inertia slider associated with each section of the document visually reflects these levels.", + "es": "**Fórmula simplificada:** `Seuil = M + (1-M) × (1 - (T/W)^G)`\n\nEl **gradiente G** controla la curva de inercia: cuanto más alto es G, más alto se mantiene el umbral, incluso con una participación creciente.\n\n**Baja** — G=0.1, M=50%\n- La curva desciende muy rápido hacia el 50%\n- A partir del 10% de participación, el umbral se acerca a la mayoría simple\n- *Uso: anexos, recomendaciones, consejos*\n\n**Estándar** — G=0.2, M=50%\n- Descenso progresivo, equilibrio entre estabilidad y reactividad\n- Con un 25% de participación, el umbral ronda el 65%\n- *Uso: compromisos fundamentales y técnicos*\n\n**Alta** — G=0.4, M=60%\n- Curva resistente, el umbral se mantiene elevado durante mucho tiempo\n- Supermayoría del 60% incluso con participación plena\n- *Uso: la propia fórmula de votación*\n\n**Muy alta** — G=0.6, M=66%\n- Se requiere casi unanimidad con cualquier nivel de participación\n- Solo un consenso masivo (66%+) puede modificar este ajuste\n- *Uso: el ajuste de la inercia (metaprotección)*\n\nEl cursor de inercia asociado a cada sección del documento refleja visualmente estos niveles." + } + } + ] + }, + { + "code": "O1", + "section": "ordonnancement", + "position": 33, + "inertia": "standard", + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Ordonnancement du document", + "en": "Ordering of the document", + "es": "Ordenación del documento" + }, + "body": { + "fr": "L'ordre de présentation des items dans le document est lui-même soumis au vote. Toute proposition de réorganisation doit atteindre le seuil d'adoption avec l'inertie standard.", + "en": "The order in which items are presented in the document is itself subject to vote. Any reorganisation proposal must reach the adoption threshold with standard inertia.", + "es": "El orden de presentación de los elementos en el documento está a su vez sometido a votación. Toda propuesta de reorganización debe alcanzar el umbral de adopción con la inercia estándar." + } + } + ] + } + ] +}