v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
"""Sanctuary service: immutable archival to IPFS + on-chain hash.
|
||||
|
||||
The sanctuary is the immutable layer of libreDecision. Every adopted
|
||||
document version, decision result, or vote tally is hashed (SHA-256),
|
||||
stored on IPFS, and anchored on-chain via system.remark.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.sanctuary import SanctuaryEntry
|
||||
from app.services import ipfs_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def archive_to_sanctuary(
|
||||
entry_type: str,
|
||||
reference_id: uuid.UUID,
|
||||
content: str,
|
||||
title: str,
|
||||
db: AsyncSession,
|
||||
) -> SanctuaryEntry:
|
||||
"""Hash content and create a sanctuary entry.
|
||||
|
||||
Pipeline:
|
||||
1. Hash content (SHA-256)
|
||||
2. Try to upload to IPFS via ipfs_service (catch errors, log, continue)
|
||||
3. Try to anchor on-chain via blockchain_service (catch errors, log, continue)
|
||||
4. Create SanctuaryEntry with whatever succeeded
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entry_type:
|
||||
Type of the archived entity (``"document"``, ``"decision"``,
|
||||
``"vote_result"``).
|
||||
reference_id:
|
||||
UUID of the source entity (document, decision, or vote session).
|
||||
content:
|
||||
The full text content to archive and hash.
|
||||
title:
|
||||
Human-readable title for the archive entry.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
SanctuaryEntry
|
||||
The newly created sanctuary entry with content_hash set.
|
||||
"""
|
||||
# 1. Compute SHA-256 hash of the content
|
||||
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
# Build metadata
|
||||
metadata: dict = {
|
||||
"archived_at": datetime.now(timezone.utc).isoformat(),
|
||||
"entry_type": entry_type,
|
||||
"content_length": len(content),
|
||||
}
|
||||
|
||||
ipfs_cid: str | None = None
|
||||
chain_tx_hash: str | None = None
|
||||
chain_block: int | None = None
|
||||
|
||||
# 2. Try to upload to IPFS
|
||||
try:
|
||||
ipfs_cid = await ipfs_service.upload_to_ipfs(content)
|
||||
if ipfs_cid:
|
||||
# Pin the content to keep it available
|
||||
await ipfs_service.pin(ipfs_cid)
|
||||
metadata["ipfs_cid"] = ipfs_cid
|
||||
logger.info("Contenu archive sur IPFS: CID=%s", ipfs_cid)
|
||||
else:
|
||||
logger.warning("Upload IPFS echoue (retour None) pour %s:%s", entry_type, reference_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Erreur lors de l'upload IPFS pour %s:%s",
|
||||
entry_type, reference_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 3. Try to anchor on-chain (still a structured stub)
|
||||
try:
|
||||
chain_tx_hash, chain_block = await _anchor_on_chain(content_hash)
|
||||
if chain_tx_hash:
|
||||
metadata["chain_tx_hash"] = chain_tx_hash
|
||||
metadata["chain_block"] = chain_block
|
||||
logger.info("Hash ancre on-chain: tx=%s block=%s", chain_tx_hash, chain_block)
|
||||
except NotImplementedError:
|
||||
logger.info("Ancrage on-chain pas encore implemente, etape ignoree")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Erreur lors de l'ancrage on-chain pour %s:%s",
|
||||
entry_type, reference_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 4. Create SanctuaryEntry with whatever succeeded
|
||||
entry = SanctuaryEntry(
|
||||
entry_type=entry_type,
|
||||
reference_id=reference_id,
|
||||
title=title,
|
||||
content_hash=content_hash,
|
||||
ipfs_cid=ipfs_cid,
|
||||
chain_tx_hash=chain_tx_hash,
|
||||
chain_block=chain_block,
|
||||
metadata_json=json.dumps(metadata, ensure_ascii=False),
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
async def verify_entry(
|
||||
entry_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""Verify the integrity of a sanctuary entry.
|
||||
|
||||
Re-fetches the content (from IPFS if available) and re-hashes it
|
||||
to compare with the stored content_hash.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entry_id:
|
||||
UUID of the SanctuaryEntry to verify.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Verification result with keys:
|
||||
- ``entry_id``: UUID of the entry
|
||||
- ``valid``: bool indicating if the hash matches
|
||||
- ``stored_hash``: the stored content_hash
|
||||
- ``computed_hash``: the re-computed hash (or None if content unavailable)
|
||||
- ``source``: where the content was fetched from (``"ipfs"`` or ``"unavailable"``)
|
||||
- ``detail``: human-readable detail message
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the entry is not found.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(SanctuaryEntry).where(SanctuaryEntry.id == entry_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
raise ValueError(f"Entree sanctuaire introuvable : {entry_id}")
|
||||
|
||||
stored_hash = entry.content_hash
|
||||
computed_hash: str | None = None
|
||||
source = "unavailable"
|
||||
|
||||
# Try to re-fetch content from IPFS
|
||||
if entry.ipfs_cid:
|
||||
try:
|
||||
content_bytes = await ipfs_service.get_from_ipfs(entry.ipfs_cid)
|
||||
if content_bytes is not None:
|
||||
computed_hash = hashlib.sha256(content_bytes).hexdigest()
|
||||
source = "ipfs"
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Impossible de recuperer le contenu IPFS pour verification (CID=%s)",
|
||||
entry.ipfs_cid,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if computed_hash is None:
|
||||
return {
|
||||
"entry_id": entry.id,
|
||||
"valid": False,
|
||||
"stored_hash": stored_hash,
|
||||
"computed_hash": None,
|
||||
"source": source,
|
||||
"detail": "Contenu indisponible pour la verification",
|
||||
}
|
||||
|
||||
is_valid = computed_hash == stored_hash
|
||||
return {
|
||||
"entry_id": entry.id,
|
||||
"valid": is_valid,
|
||||
"stored_hash": stored_hash,
|
||||
"computed_hash": computed_hash,
|
||||
"source": source,
|
||||
"detail": "Integrite verifiee" if is_valid else "Hash different - contenu potentiellement altere",
|
||||
}
|
||||
|
||||
|
||||
async def get_entries_by_reference(
|
||||
reference_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> list[SanctuaryEntry]:
|
||||
"""Query all sanctuary entries for a given reference_id.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
reference_id:
|
||||
UUID of the referenced entity (document, decision, etc.).
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[SanctuaryEntry]
|
||||
All entries matching the reference_id, ordered by creation date desc.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(SanctuaryEntry)
|
||||
.where(SanctuaryEntry.reference_id == reference_id)
|
||||
.order_by(SanctuaryEntry.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _anchor_on_chain(content_hash: str) -> tuple[str | None, int | None]:
|
||||
"""Anchor a content hash on-chain via system.remark.
|
||||
|
||||
Currently a stub. When implemented, this will use substrate-interface
|
||||
to submit a system.remark extrinsic containing the content hash.
|
||||
|
||||
Example::
|
||||
|
||||
from substrateinterface import SubstrateInterface
|
||||
from app.config import settings
|
||||
|
||||
substrate = SubstrateInterface(url=settings.DUNITER_RPC_URL)
|
||||
call = substrate.compose_call(
|
||||
call_module="System",
|
||||
call_function="remark",
|
||||
call_params={"remark": f"libredecision:sanctuary:{content_hash}"},
|
||||
)
|
||||
extrinsic = substrate.create_signed_extrinsic(call=call, keypair=keypair)
|
||||
receipt = substrate.submit_extrinsic(extrinsic, wait_for_inclusion=True)
|
||||
return receipt.extrinsic_hash, receipt.block_number
|
||||
|
||||
Parameters
|
||||
----------
|
||||
content_hash:
|
||||
The SHA-256 hash to anchor.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[str | None, int | None]
|
||||
(tx_hash, block_number) or (None, None) if not implemented.
|
||||
"""
|
||||
raise NotImplementedError("Ancrage on-chain pas encore implemente")
|
||||
Reference in New Issue
Block a user