From 0d77b64f6b90c76f3806d889a87da171369a2c99 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 30 Jun 2026 08:14:32 +0200 Subject: [PATCH] feat(annuaire): service offers + subscription verbs/API/UI + pull federation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - annuaire/verbs.py: offer_service, revoke_offer, subscribe, approve_subscription, reject_subscription, ingest_offer, subscription_state, _get_offers/_get_offer helpers. Security: self-certifying authorship enforced; only offer provider may approve/reject/revoke; approve entries from non-providers are ignored. - api/main.py: GET /services, POST /service/offer, POST /service/{id}/revoke, POST /service/{id}/subscribe (returns derived state), GET /subscriptions (?mine=/?pending_for=), POST /subscription/{id}/approve, POST /subscription/{id}/reject, POST /services/pull (federation pull with 5s timeout, never crashes). - www/annuaire/index.html: Services + My subscriptions panels with per-offer Subscribe button, state badges, defensive fetch. - tests/test_services.py: 28 tests — AUTO→approved, PENDING→approve→APPROVED, PENDING→reject→REJECTED, non-provider-approve blocked at call layer and defense-in-depth (forged log entry ignored), non-provider revoke blocked, provider revoke removes offer + blocks new subscribe, OBSERVED subscribe blocked, ingest_offer valid/wrong-key/tampered/forged all tested. - debian/changelog: bump to 0.1.3-1~bookworm1. --- packages/secubox-annuaire/annuaire/verbs.py | 485 +++++++++++++++ packages/secubox-annuaire/api/main.py | 239 ++++++++ packages/secubox-annuaire/debian/changelog | 15 + .../secubox-annuaire/tests/test_services.py | 567 ++++++++++++++++-- .../secubox-annuaire/www/annuaire/index.html | 70 +++ 5 files changed, 1330 insertions(+), 46 deletions(-) diff --git a/packages/secubox-annuaire/annuaire/verbs.py b/packages/secubox-annuaire/annuaire/verbs.py index d33fa9d2..d3609b05 100644 --- a/packages/secubox-annuaire/annuaire/verbs.py +++ b/packages/secubox-annuaire/annuaire/verbs.py @@ -35,6 +35,7 @@ from .crypto import canonical_bytes, did_from_pubkey, sign, verify from .log import Journal from .model import ( GENESIS_HASH, + ApprovalMode, Identity, Invitation, MemberState, @@ -44,6 +45,9 @@ from .model import ( QuorumRule, RevocationNotice, RevocationScope, + ServiceOffer, + Subscription, + SubscriptionState, now_rfc3339, ) @@ -1023,3 +1027,484 @@ def emancipate( "remove_anchor": remove_anchor, "milestones": milestones, } + + +# --------------------------------------------------------------------------- +# Service offers + subscriptions +# --------------------------------------------------------------------------- + +def _get_offers(journal: Journal) -> List[Dict]: + """Return a list of the latest non-revoked, self-authored ServiceOffer payloads. + + Self-authored: entry.author == offer.provider. + Non-revoked: no SERVICE_REVOKE_OFFER authored by the same provider exists after + the offer entry at the same service_id. + + Returns one entry per service_id (the latest self-authored SERVICE_OFFER that has + not been revoked by its provider). + """ + # Collect latest self-authored offer per service_id (by height) + offers: Dict[str, Dict] = {} # service_id -> payload + offer_heights: Dict[str, int] = {} + revoked_ids: set = set() + + for entry in journal.iter_entries(): + if entry.op == Op.SERVICE_OFFER and entry.payload_type == "ServiceOffer": + sid = entry.payload.get("service_id") + provider = entry.payload.get("provider") + if sid and provider and entry.author == provider: + if entry.height > offer_heights.get(sid, -1): + offers[sid] = entry.payload + offer_heights[sid] = entry.height + elif entry.op == Op.SERVICE_REVOKE_OFFER: + sid = entry.payload.get("service_id") + provider = entry.payload.get("provider") + if sid and provider: + # Only count as revoked if authored by the provider of the offer + offer = offers.get(sid) + if offer and offer.get("provider") == entry.author: + revoked_ids.add(sid) + + return [v for k, v in offers.items() if k not in revoked_ids] + + +def _get_offer(journal: Journal, service_id: str) -> Optional[Dict]: + """Return the latest non-revoked self-authored ServiceOffer for service_id, or None.""" + for offer in _get_offers(journal): + if offer.get("service_id") == service_id: + return offer + return None + + +def subscription_state(journal: Journal, subscription_id: str) -> SubscriptionState: + """Derive the current SubscriptionState for subscription_id from the log. + + Rules (in priority order): + 1. REVOKED — a SERVICE_REVOKE_SUB entry exists authored by the subscriber. + 2. REJECTED — a SERVICE_REJECT entry exists authored by the offer's provider. + 3. APPROVED — offer.approval_mode == AUTO, OR a SERVICE_APPROVE entry authored + by the offer's provider exists. + 4. PENDING — otherwise. + + Entries authored by the wrong party do NOT count (defense in depth). + """ + # Find the subscription entry + sub_payload: Optional[Dict] = None + for entry in journal.iter_entries(): + if (entry.op == Op.SERVICE_SUBSCRIBE + and entry.payload_type == "Subscription" + and entry.payload.get("subscription_id") == subscription_id): + sub_payload = entry.payload + break + + if sub_payload is None: + raise ValueError(f"subscription_state: subscription {subscription_id!r} not found") + + subscriber = sub_payload.get("subscriber") + service_id = sub_payload.get("service_id") + + # Resolve the offer (may be revoked — we still need provider for auth checks) + offer_provider: Optional[str] = None + offer_approval_mode: str = ApprovalMode.PENDING.value + for entry in journal.iter_entries(): + if (entry.op == Op.SERVICE_OFFER + and entry.payload_type == "ServiceOffer" + and entry.payload.get("service_id") == service_id): + provider = entry.payload.get("provider") + if provider and entry.author == provider: + offer_provider = provider + offer_approval_mode = entry.payload.get( + "approval_mode", ApprovalMode.PENDING.value + ) + # (Use the latest found — could be overridden by a newer offer for same service_id; + # iterate the full log so the last match wins.) + + approved = False + rejected = False + revoked_sub = False + + for entry in journal.iter_entries(): + payload = entry.payload + if payload.get("subscription_id") != subscription_id: + continue + + if entry.op == Op.SERVICE_REVOKE_SUB and entry.author == subscriber: + revoked_sub = True + + if entry.op == Op.SERVICE_APPROVE and offer_provider and entry.author == offer_provider: + approved = True + + if entry.op == Op.SERVICE_REJECT and offer_provider and entry.author == offer_provider: + rejected = True + + if revoked_sub: + return SubscriptionState.REVOKED + if rejected: + return SubscriptionState.REJECTED + if offer_approval_mode == ApprovalMode.AUTO.value or approved: + return SubscriptionState.APPROVED + return SubscriptionState.PENDING + + +def offer_service( + journal: Journal, + provider_priv: bytes, + provider_did: str, + *, + name: str, + kind: str, + endpoint: str, + scope: Optional[Dict] = None, + approval_mode: str = "auto", + description: str = "", +) -> ServiceOffer: + """SERVICE_OFFER: publish a signed service offer. + + Self-certifying: entry.author == provider_did. + + Args: + journal: the append-only Journal. + provider_priv: provider's 32-byte raw Ed25519 private key. + provider_did: provider's did:plc. + name: human-readable name. + kind: service kind (e.g. "module", "api", "mirror"). + endpoint: mesh URL or local path. + scope: optional scope dict. + approval_mode: "auto" or "pending" (default: "auto"). + description: human-readable description. + + Returns: + The signed ServiceOffer. + """ + service_id = _rand_hex(32) + offer = ServiceOffer( + service_id=service_id, + provider=provider_did, + name=name, + kind=kind, + endpoint=endpoint, + scope=scope or {}, + approval_mode=ApprovalMode(approval_mode), + description=description, + ) + full = offer.model_dump() + payload = {k: v for k, v in full.items() if k not in ("sig", "signer_did")} + sig_hex = sign(provider_priv, canonical_bytes(payload)) + + provider_pubkey = _get_inviter_pubkey(journal, provider_did) + journal.append( + op=Op.SERVICE_OFFER, + payload_type="ServiceOffer", + payload=payload, + author=provider_did, + sig=sig_hex, + author_pubkey_hex=provider_pubkey, + ) + + return ServiceOffer(**{**payload, "sig": sig_hex, "signer_did": provider_did}) + + +def revoke_offer( + journal: Journal, + provider_priv: bytes, + provider_did: str, + service_id: str, +) -> Dict[str, Any]: + """SERVICE_REVOKE_OFFER: withdraw a service offer. + + Only the offer's original provider may revoke it. + + Args: + journal: the append-only Journal. + provider_priv: provider's 32-byte raw Ed25519 private key. + provider_did: provider's did:plc. + service_id: the service to revoke. + + Returns: + Dict with revocation record. + + Raises: + PermissionError: if provider_did is not the offer's provider. + ValueError: if the offer does not exist. + """ + offer = _get_offer(journal, service_id) + if offer is None: + raise ValueError(f"revoke_offer: service {service_id!r} not found or already revoked") + if offer.get("provider") != provider_did: + raise PermissionError( + f"revoke_offer: {provider_did} is not the provider of service {service_id!r}" + ) + + revoke_payload: Dict[str, Any] = { + "service_id": service_id, + "provider": provider_did, + "revoked_at": now_rfc3339(), + } + sig_hex = sign(provider_priv, canonical_bytes(revoke_payload)) + + provider_pubkey = _get_inviter_pubkey(journal, provider_did) + journal.append( + op=Op.SERVICE_REVOKE_OFFER, + payload_type="ServiceRevokeOffer", + payload=revoke_payload, + author=provider_did, + sig=sig_hex, + author_pubkey_hex=provider_pubkey, + ) + + return {"status": "revoked", "service_id": service_id, "provider": provider_did} + + +def subscribe( + journal: Journal, + subscriber_priv: bytes, + subscriber_did: str, + service_id: str, +) -> Subscription: + """SERVICE_SUBSCRIBE: request access to a service offer. + + Preconditions: + - The offer must exist and not be revoked. + - The subscriber must be a non-revoked MEMBER. + + Args: + journal: the append-only Journal. + subscriber_priv: subscriber's 32-byte raw Ed25519 private key. + subscriber_did: subscriber's did:plc. + service_id: the service to subscribe to. + + Returns: + The signed Subscription. + + Raises: + ValueError: if the offer does not exist or is revoked. + PermissionError: if the subscriber is not a non-revoked MEMBER. + """ + offer = _get_offer(journal, service_id) + if offer is None: + raise ValueError(f"subscribe: service {service_id!r} does not exist or has been revoked") + + if not _is_non_revoked_member(journal, subscriber_did): + raise PermissionError( + f"subscribe: {subscriber_did} is not a non-revoked MEMBER — " + "only MEMBERs may subscribe to services" + ) + + subscription_id = _rand_hex(32) + sub = Subscription( + subscription_id=subscription_id, + subscriber=subscriber_did, + service_id=service_id, + ) + full = sub.model_dump() + payload = {k: v for k, v in full.items() if k not in ("sig", "signer_did")} + sig_hex = sign(subscriber_priv, canonical_bytes(payload)) + + subscriber_pubkey = _get_inviter_pubkey(journal, subscriber_did) + journal.append( + op=Op.SERVICE_SUBSCRIBE, + payload_type="Subscription", + payload=payload, + author=subscriber_did, + sig=sig_hex, + author_pubkey_hex=subscriber_pubkey, + ) + + return Subscription(**{**payload, "sig": sig_hex, "signer_did": subscriber_did}) + + +def approve_subscription( + journal: Journal, + approver_priv: bytes, + approver_did: str, + subscription_id: str, +) -> Dict[str, Any]: + """SERVICE_APPROVE: approve a pending subscription. + + Only the provider of the subscribed service may approve. + + Args: + journal: the append-only Journal. + approver_priv: approver's 32-byte raw Ed25519 private key. + approver_did: approver's did:plc. + subscription_id: the subscription to approve. + + Returns: + Dict with approval record. + + Raises: + PermissionError: if approver_did is not the service provider. + ValueError: if the subscription does not exist. + """ + sub_payload = _get_subscription_payload(journal, subscription_id) + if sub_payload is None: + raise ValueError(f"approve_subscription: subscription {subscription_id!r} not found") + + service_id = sub_payload.get("service_id") + offer = _get_offer(journal, service_id) + if offer is None: + # Offer may have been revoked — still resolve the provider for auth + offer = _find_any_offer(journal, service_id) + if offer is None or offer.get("provider") != approver_did: + raise PermissionError( + f"approve_subscription: {approver_did} is not the provider of service {service_id!r}" + ) + + approve_payload: Dict[str, Any] = { + "subscription_id": subscription_id, + "service_id": service_id, + "approver": approver_did, + "approved_at": now_rfc3339(), + } + sig_hex = sign(approver_priv, canonical_bytes(approve_payload)) + + approver_pubkey = _get_inviter_pubkey(journal, approver_did) + journal.append( + op=Op.SERVICE_APPROVE, + payload_type="ServiceApprove", + payload=approve_payload, + author=approver_did, + sig=sig_hex, + author_pubkey_hex=approver_pubkey, + ) + + return {"status": "approved", "subscription_id": subscription_id, "approver": approver_did} + + +def reject_subscription( + journal: Journal, + rejecter_priv: bytes, + rejecter_did: str, + subscription_id: str, +) -> Dict[str, Any]: + """SERVICE_REJECT: reject a pending subscription. + + Only the provider of the subscribed service may reject. + + Args: + journal: the append-only Journal. + rejecter_priv: rejecter's 32-byte raw Ed25519 private key. + rejecter_did: rejecter's did:plc. + subscription_id: the subscription to reject. + + Returns: + Dict with rejection record. + + Raises: + PermissionError: if rejecter_did is not the service provider. + ValueError: if the subscription does not exist. + """ + sub_payload = _get_subscription_payload(journal, subscription_id) + if sub_payload is None: + raise ValueError(f"reject_subscription: subscription {subscription_id!r} not found") + + service_id = sub_payload.get("service_id") + offer = _get_offer(journal, service_id) + if offer is None: + offer = _find_any_offer(journal, service_id) + if offer is None or offer.get("provider") != rejecter_did: + raise PermissionError( + f"reject_subscription: {rejecter_did} is not the provider of service {service_id!r}" + ) + + reject_payload: Dict[str, Any] = { + "subscription_id": subscription_id, + "service_id": service_id, + "rejecter": rejecter_did, + "rejected_at": now_rfc3339(), + } + sig_hex = sign(rejecter_priv, canonical_bytes(reject_payload)) + + rejecter_pubkey = _get_inviter_pubkey(journal, rejecter_did) + journal.append( + op=Op.SERVICE_REJECT, + payload_type="ServiceReject", + payload=reject_payload, + author=rejecter_did, + sig=sig_hex, + author_pubkey_hex=rejecter_pubkey, + ) + + return {"status": "rejected", "subscription_id": subscription_id, "rejecter": rejecter_did} + + +def ingest_offer( + journal: Journal, + offer: ServiceOffer, + provider_pubkey_hex: str, +) -> Dict[str, Any]: + """Federate a remote signed ServiceOffer into the local journal. + + Verifies the offer's signature against the provider's public key BEFORE + writing anything to the log. A bad or missing sig raises ValueError. + + The offer is stored as authored by offer.provider (self-certifying + federation — the remote node's signature is the authority). + + Args: + journal: the append-only Journal. + offer: a ServiceOffer received from a remote node. + provider_pubkey_hex: the provider's hex Ed25519 public key (for sig verification). + + Returns: + Dict with ingestion record. + + Raises: + ValueError: if the offer has no signature or the signature is invalid. + """ + if not offer.sig: + raise ValueError("ingest_offer: offer carries no signature — cannot federate unsigned offer") + + # Reconstruct the canonical payload that was signed: model_dump minus sig/signer_did + full = offer.model_dump() + payload = {k: v for k, v in full.items() if k not in ("sig", "signer_did")} + + if not verify(provider_pubkey_hex, canonical_bytes(payload), offer.sig): + raise ValueError( + f"ingest_offer: signature verification failed for service {offer.service_id!r} " + "from provider {offer.provider!r} — offer rejected" + ) + + journal.append( + op=Op.SERVICE_OFFER, + payload_type="ServiceOffer", + payload=payload, + author=offer.provider, + sig=offer.sig, + author_pubkey_hex=provider_pubkey_hex, + ) + + return {"status": "ingested", "service_id": offer.service_id, "provider": offer.provider} + + +# --------------------------------------------------------------------------- +# Internal helpers for service verbs +# --------------------------------------------------------------------------- + +def _get_subscription_payload(journal: Journal, subscription_id: str) -> Optional[Dict]: + """Return the Subscription payload dict for subscription_id, or None.""" + for entry in journal.iter_entries(): + if (entry.op == Op.SERVICE_SUBSCRIBE + and entry.payload_type == "Subscription" + and entry.payload.get("subscription_id") == subscription_id): + return entry.payload + return None + + +def _find_any_offer(journal: Journal, service_id: str) -> Optional[Dict]: + """Return the latest self-authored ServiceOffer for service_id (even if revoked). + + Used for provider-authority checks where the offer may already be revoked. + """ + best: Optional[Dict] = None + best_height = -1 + for entry in journal.iter_entries(): + if (entry.op == Op.SERVICE_OFFER + and entry.payload_type == "ServiceOffer" + and entry.payload.get("service_id") == service_id): + provider = entry.payload.get("provider") + if provider and entry.author == provider: + if entry.height > best_height: + best_height = entry.height + best = entry.payload + return best diff --git a/packages/secubox-annuaire/api/main.py b/packages/secubox-annuaire/api/main.py index 50813cfb..b8d39c5e 100644 --- a/packages/secubox-annuaire/api/main.py +++ b/packages/secubox-annuaire/api/main.py @@ -394,3 +394,242 @@ async def emancipate(req: EmancipateRequest): except ValueError as e: raise HTTPException(400, str(e)) return result + + +# --------------------------------------------------------------------------- +# Service offer + subscription request/response models +# --------------------------------------------------------------------------- + + +class ServiceOfferRequest(BaseModel): + provider_did: str = Field(..., pattern=r"^did:plc:[0-9a-f]{32}$") + provider_priv_hex: str + name: str + kind: str + endpoint: str + scope: Optional[Dict[str, Any]] = None + approval_mode: str = "auto" + description: str = "" + + +class RevokeOfferRequest(BaseModel): + provider_did: str = Field(..., pattern=r"^did:plc:[0-9a-f]{32}$") + provider_priv_hex: str + + +class SubscribeRequest(BaseModel): + subscriber_did: str = Field(..., pattern=r"^did:plc:[0-9a-f]{32}$") + subscriber_priv_hex: str + + +class SubscriptionActionRequest(BaseModel): + actor_did: str = Field(..., pattern=r"^did:plc:[0-9a-f]{32}$") + actor_priv_hex: str + + +class PullServicesRequest(BaseModel): + base_url: str + + +# --------------------------------------------------------------------------- +# Service offer + subscription endpoints +# --------------------------------------------------------------------------- + + +@app.get("/services") +async def list_services(): + """List all non-revoked service offers (public).""" + from annuaire.verbs import _get_offers # noqa: PLC0415 + j = get_journal() + return {"services": _get_offers(j)} + + +@app.post("/service/offer", dependencies=[Depends(_require_jwt)]) +async def post_service_offer(req: ServiceOfferRequest): + """SERVICE_OFFER: publish a signed service offer.""" + from annuaire.verbs import offer_service # noqa: PLC0415 + priv = _priv_from_hex(req.provider_priv_hex) + j = get_journal() + try: + offer = offer_service( + j, + priv, + req.provider_did, + name=req.name, + kind=req.kind, + endpoint=req.endpoint, + scope=req.scope, + approval_mode=req.approval_mode, + description=req.description, + ) + except PermissionError as e: + raise HTTPException(403, str(e)) + except Exception as e: + raise HTTPException(400, str(e)) + return offer.model_dump() + + +@app.post("/service/{service_id}/revoke", dependencies=[Depends(_require_jwt)]) +async def revoke_service_offer(service_id: str, req: RevokeOfferRequest): + """SERVICE_REVOKE_OFFER: withdraw a service offer (provider only).""" + from annuaire.verbs import revoke_offer # noqa: PLC0415 + priv = _priv_from_hex(req.provider_priv_hex) + j = get_journal() + try: + result = revoke_offer(j, priv, req.provider_did, service_id) + except PermissionError as e: + raise HTTPException(403, str(e)) + except ValueError as e: + raise HTTPException(404, str(e)) + return result + + +@app.post("/service/{service_id}/subscribe", dependencies=[Depends(_require_jwt)]) +async def subscribe_to_service(service_id: str, req: SubscribeRequest): + """SERVICE_SUBSCRIBE: subscribe to a service offer (MEMBER only).""" + from annuaire.verbs import subscribe, subscription_state # noqa: PLC0415 + priv = _priv_from_hex(req.subscriber_priv_hex) + j = get_journal() + try: + sub = subscribe(j, priv, req.subscriber_did, service_id) + except PermissionError as e: + raise HTTPException(403, str(e)) + except ValueError as e: + raise HTTPException(404, str(e)) + state = subscription_state(j, sub.subscription_id) + result = sub.model_dump() + result["state"] = state.value + return result + + +@app.get("/subscriptions") +async def list_subscriptions(mine: Optional[str] = None, pending_for: Optional[str] = None): + """List subscriptions with derived state (public read). + + Optional filters: + ?mine= — subscriptions by this subscriber + ?pending_for= — pending subscriptions for this provider + """ + from annuaire.verbs import subscription_state, _find_any_offer # noqa: PLC0415 + from annuaire.model import Op as _Op, SubscriptionState as _SS # noqa: PLC0415 + j = get_journal() + + # Collect all subscription entries + subs = [] + for entry in j.iter_entries(): + if entry.op == _Op.SERVICE_SUBSCRIBE and entry.payload_type == "Subscription": + sub_id = entry.payload.get("subscription_id") + if sub_id is None: + continue + try: + state = subscription_state(j, sub_id) + except ValueError: + continue + record = dict(entry.payload) + record["state"] = state.value + # Resolve provider for this subscription + offer = _find_any_offer(j, entry.payload.get("service_id", "")) + record["provider"] = offer.get("provider") if offer else None + subs.append(record) + + # Apply filters + if mine: + subs = [s for s in subs if s.get("subscriber") == mine] + if pending_for: + subs = [s for s in subs + if s.get("provider") == pending_for + and s.get("state") == _SS.PENDING.value] + + return {"subscriptions": subs} + + +@app.post("/subscription/{subscription_id}/approve", dependencies=[Depends(_require_jwt)]) +async def approve_sub(subscription_id: str, req: SubscriptionActionRequest): + """SERVICE_APPROVE: approve a subscription (provider only).""" + from annuaire.verbs import approve_subscription # noqa: PLC0415 + priv = _priv_from_hex(req.actor_priv_hex) + j = get_journal() + try: + result = approve_subscription(j, priv, req.actor_did, subscription_id) + except PermissionError as e: + raise HTTPException(403, str(e)) + except ValueError as e: + raise HTTPException(404, str(e)) + return result + + +@app.post("/subscription/{subscription_id}/reject", dependencies=[Depends(_require_jwt)]) +async def reject_sub(subscription_id: str, req: SubscriptionActionRequest): + """SERVICE_REJECT: reject a subscription (provider only).""" + from annuaire.verbs import reject_subscription # noqa: PLC0415 + priv = _priv_from_hex(req.actor_priv_hex) + j = get_journal() + try: + result = reject_subscription(j, priv, req.actor_did, subscription_id) + except PermissionError as e: + raise HTTPException(403, str(e)) + except ValueError as e: + raise HTTPException(404, str(e)) + return result + + +@app.post("/services/pull", dependencies=[Depends(_require_jwt)]) +async def pull_services(req: PullServicesRequest): + """Federation pull: fetch /services from a remote node and ingest valid offers. + + Fetches /api/v1/annuaire/services with a 5s timeout. + For each offer, verifies the signature and ingests into the local journal. + Never crashes on offline targets — returns partial results with errors. + """ + import urllib.request # noqa: PLC0415 + import urllib.error # noqa: PLC0415 + from annuaire.model import ServiceOffer as _ServiceOffer # noqa: PLC0415 + from annuaire.verbs import ingest_offer # noqa: PLC0415 + from annuaire.verbs import _get_offers # noqa: PLC0415 + + j = get_journal() + ingested = 0 + rejected = 0 + error_msg: Optional[str] = None + + try: + url = req.base_url.rstrip("/") + "/api/v1/annuaire/services" + req_http = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req_http, timeout=5) as resp: + import json as _json # noqa: PLC0415 + data = _json.loads(resp.read()) + remote_offers: List[Dict[str, Any]] = data.get("services", []) + except Exception as e: + return {"ingested": 0, "rejected": 0, "error": str(e)} + + # Skip offers we already have (by service_id) to avoid duplicate key errors + known_sids = {o.get("service_id") for o in _get_offers(j)} + + for raw in remote_offers: + sid = raw.get("service_id") + if sid in known_sids: + continue # already ingested + try: + provider_pubkey_hex = raw.get("pubkey") or raw.get("provider_pubkey") + # The offer payload carries no pubkey directly — we need to resolve it. + # For now, try to find the pubkey from the local identity store. + # If the provider is unknown locally, we cannot verify — skip safely. + if not provider_pubkey_hex: + # Try resolving from local log + from annuaire.verbs import _get_inviter_pubkey # noqa: PLC0415 + provider_pubkey_hex = _get_inviter_pubkey(j, raw.get("provider", "")) + if not provider_pubkey_hex: + rejected += 1 + continue + # Reconstruct ServiceOffer (sig must be present) + offer_obj = _ServiceOffer(**raw) + ingest_offer(j, offer_obj, provider_pubkey_hex) + ingested += 1 + known_sids.add(sid) + except Exception as exc: + rejected += 1 + + result: Dict[str, Any] = {"ingested": ingested, "rejected": rejected} + if error_msg: + result["error"] = error_msg + return result diff --git a/packages/secubox-annuaire/debian/changelog b/packages/secubox-annuaire/debian/changelog index 970a439b..76cc356c 100644 --- a/packages/secubox-annuaire/debian/changelog +++ b/packages/secubox-annuaire/debian/changelog @@ -1,3 +1,18 @@ +secubox-annuaire (0.1.3-1~bookworm1) bookworm; urgency=medium + + * feat: service offers + subscription verbs, API and UI + pull federation + - annuaire/verbs.py: offer_service, revoke_offer, subscribe, + approve_subscription, reject_subscription, ingest_offer, + subscription_state, _get_offers, _get_offer helpers. + - api/main.py: GET /services, POST /service/offer, + POST /service/{id}/revoke, POST /service/{id}/subscribe, + GET /subscriptions, POST /subscription/{id}/approve, + POST /subscription/{id}/reject, POST /services/pull. + - www/annuaire/index.html: Services section + My subscriptions panel. + - tests/test_services.py: 28 new tests covering all security invariants. + + -- Gerald KERMA Tue, 30 Jun 2026 12:00:00 +0200 + secubox-annuaire (0.1.2-1~bookworm1) bookworm; urgency=medium * ui: minimal shared-template dashboard (chain integrity, merkle root, diff --git a/packages/secubox-annuaire/tests/test_services.py b/packages/secubox-annuaire/tests/test_services.py index a47866fa..be91d62e 100644 --- a/packages/secubox-annuaire/tests/test_services.py +++ b/packages/secubox-annuaire/tests/test_services.py @@ -5,7 +5,21 @@ """ SecuBox-Deb :: secubox-annuaire :: tests/test_services.py Pytest coverage for service offers + subscription feature. + +Tests: + - offer_service → appears in _get_offers; self-authored guard. + - subscribe to AUTO offer → state APPROVED without any provider action. + - subscribe to PENDING offer → state PENDING; provider approve → APPROVED. + - subscribe to PENDING offer → provider reject → REJECTED. + - Non-provider approve → PermissionError (defense-in-depth: approve entry + authored by a wrong party does NOT flip the state). + - revoke_offer by non-provider → PermissionError. + - revoke_offer by provider → offer gone; new subscribe fails. + - subscribe by OBSERVED (non-member) → PermissionError. + - ingest_offer with valid sig → ingested; tampered/forged sig → ValueError. + - Approve entry authored by non-provider does NOT count as approved. """ +import hashlib import os import tempfile @@ -23,6 +37,17 @@ from annuaire.model import ( SubscriptionState, now_rfc3339, ) +from annuaire.verbs import ( + _get_offer, + _get_offers, + approve_subscription, + ingest_offer, + offer_service, + reject_subscription, + revoke_offer, + subscribe, + subscription_state, +) # --------------------------------------------------------------------------- @@ -34,67 +59,63 @@ def tmp_journal(tmp_path): return Journal(str(tmp_path / "test.db")) -def make_member(journal: Journal): - """Create a MEMBER node: auto_add + invite + accept_invite.""" - # Founder key - founder_priv, founder_pub = generate_keypair() - founder_did = did_from_pubkey(founder_pub) +def _make_member(journal: Journal): + """Bootstrap a MEMBER node: post a self-authored MEMBER Identity. Returns (priv, pub, did).""" + priv, pub = generate_keypair() + did = did_from_pubkey(pub) + digest = hashlib.sha256(pub).hexdigest()[:32] - # Bootstrap founder as MEMBER via auto_add + self-invite trick: - # 1. Auto-add founder as OBSERVED - import hashlib - digest = hashlib.sha256(founder_pub).hexdigest()[:32] ident = Identity( - did=founder_did, - pubkey=founder_pub.hex(), + did=did, + pubkey=pub.hex(), + self_cert_digest=digest, + state=MemberState.MEMBER, + ) + full = ident.model_dump() + payload = {k: v for k, v in full.items() if k not in ("sig", "signer_did")} + sig_hex = sign(priv, canonical_bytes(payload)) + + journal.append( + op=Op.INVITE_ACCEPT, + payload_type="Identity", + payload=payload, + author=did, + sig=sig_hex, + author_pubkey_hex=pub.hex(), + ) + return priv, pub, did + + +def _make_observed(journal: Journal): + """Bootstrap an OBSERVED node. Returns (priv, pub, did).""" + priv, pub = generate_keypair() + did = did_from_pubkey(pub) + digest = hashlib.sha256(pub).hexdigest()[:32] + + ident = Identity( + did=did, + pubkey=pub.hex(), self_cert_digest=digest, state=MemberState.OBSERVED, ) full = ident.model_dump() payload = {k: v for k, v in full.items() if k not in ("sig", "signer_did")} payload["state"] = MemberState.OBSERVED.value - sig_hex = sign(founder_priv, canonical_bytes(payload)) - ident_with_sig = Identity(**{**full, "sig": sig_hex, "signer_did": founder_did}) - from annuaire.verbs import auto_add - auto_add(journal, ident_with_sig) + sig_hex = sign(priv, canonical_bytes(payload)) - # 2. Promote to MEMBER directly (post MEMBER Identity self-authored) - ident_member = Identity( - did=founder_did, - pubkey=founder_pub.hex(), - self_cert_digest=digest, - state=MemberState.MEMBER, - invited_by=None, - ) - full_m = ident_member.model_dump() - payload_m = {k: v for k, v in full_m.items() if k not in ("sig", "signer_did")} - sig_m = sign(founder_priv, canonical_bytes(payload_m)) journal.append( - op=Op.INVITE_ACCEPT, + op=Op.AUTO_ADD, payload_type="Identity", - payload=payload_m, - author=founder_did, - sig=sig_m, - author_pubkey_hex=founder_pub.hex(), + payload=payload, + author=did, + sig=sig_hex, + author_pubkey_hex=pub.hex(), ) - return founder_priv, founder_pub, founder_did - - -def make_two_members(journal: Journal): - """Return (priv_a, pub_a, did_a, priv_b, pub_b, did_b) both MEMBER.""" - priv_a, pub_a, did_a = make_member(journal) - - priv_b, pub_b = generate_keypair() - did_b = did_from_pubkey(pub_b) - from annuaire.verbs import invite, accept_invite - inv = invite(journal, priv_a, did_a, domain="test") - accept_invite(journal, priv_b, did_b, inv) - - return priv_a, pub_a, did_a, priv_b, pub_b, did_b + return priv, pub, did # --------------------------------------------------------------------------- -# Smoke: model constructable +# Model smoke tests (pre-existing, keep them) # --------------------------------------------------------------------------- def test_model_service_offer_constructable(): @@ -142,3 +163,457 @@ def test_op_enum_has_service_members(): assert ApprovalMode.PENDING.value == "pending" assert SubscriptionState.APPROVED.value == "approved" assert SubscriptionState.PENDING.value == "pending" + + +# --------------------------------------------------------------------------- +# 1. offer_service → in _get_offers; self-authored +# --------------------------------------------------------------------------- + +def test_offer_service_appears_in_get_offers(tmp_journal): + """offer_service must result in the offer appearing in _get_offers.""" + priv, pub, did = _make_member(tmp_journal) + + offer = offer_service( + tmp_journal, priv, did, + name="TestSvc", kind="api", endpoint="http://local/api", + approval_mode="auto", + ) + + offers = _get_offers(tmp_journal) + assert len(offers) == 1 + assert offers[0]["service_id"] == offer.service_id + assert offers[0]["provider"] == did + assert offers[0]["name"] == "TestSvc" + + +def test_offer_service_multiple_offers(tmp_journal): + """Two different providers can each have an offer; both appear.""" + priv_a, pub_a, did_a = _make_member(tmp_journal) + priv_b, pub_b, did_b = _make_member(tmp_journal) + + offer_service(tmp_journal, priv_a, did_a, name="SvcA", kind="module", endpoint="/a") + offer_service(tmp_journal, priv_b, did_b, name="SvcB", kind="api", endpoint="/b") + + offers = _get_offers(tmp_journal) + assert len(offers) == 2 + sids = {o["provider"] for o in offers} + assert did_a in sids + assert did_b in sids + + +# --------------------------------------------------------------------------- +# 2. subscribe to AUTO offer → state APPROVED with no provider action +# --------------------------------------------------------------------------- + +def test_subscribe_auto_mode_immediately_approved(tmp_journal): + """AUTO approval_mode → subscription_state returns APPROVED without any provider action.""" + priv_p, pub_p, did_p = _make_member(tmp_journal) + priv_s, pub_s, did_s = _make_member(tmp_journal) + + offer = offer_service( + tmp_journal, priv_p, did_p, + name="AutoSvc", kind="api", endpoint="/auto", + approval_mode="auto", + ) + + sub = subscribe(tmp_journal, priv_s, did_s, offer.service_id) + state = subscription_state(tmp_journal, sub.subscription_id) + + assert state == SubscriptionState.APPROVED, ( + f"AUTO offer subscription must be APPROVED immediately, got {state}" + ) + + +# --------------------------------------------------------------------------- +# 3. subscribe to PENDING offer → PENDING; provider approve → APPROVED +# --------------------------------------------------------------------------- + +def test_subscribe_pending_mode_starts_pending(tmp_journal): + """PENDING approval_mode → subscription_state is PENDING until approved.""" + priv_p, pub_p, did_p = _make_member(tmp_journal) + priv_s, pub_s, did_s = _make_member(tmp_journal) + + offer = offer_service( + tmp_journal, priv_p, did_p, + name="PendingSvc", kind="api", endpoint="/pending", + approval_mode="pending", + ) + + sub = subscribe(tmp_journal, priv_s, did_s, offer.service_id) + state = subscription_state(tmp_journal, sub.subscription_id) + + assert state == SubscriptionState.PENDING, ( + f"PENDING offer subscription must start as PENDING, got {state}" + ) + + +def test_subscribe_pending_provider_approve_becomes_approved(tmp_journal): + """PENDING → provider approve → state APPROVED.""" + priv_p, pub_p, did_p = _make_member(tmp_journal) + priv_s, pub_s, did_s = _make_member(tmp_journal) + + offer = offer_service( + tmp_journal, priv_p, did_p, + name="PendingSvc", kind="api", endpoint="/pending", + approval_mode="pending", + ) + sub = subscribe(tmp_journal, priv_s, did_s, offer.service_id) + + # Before: PENDING + assert subscription_state(tmp_journal, sub.subscription_id) == SubscriptionState.PENDING + + # Provider approves + approve_subscription(tmp_journal, priv_p, did_p, sub.subscription_id) + + # After: APPROVED + state = subscription_state(tmp_journal, sub.subscription_id) + assert state == SubscriptionState.APPROVED, ( + f"After provider approval, state must be APPROVED, got {state}" + ) + + +# --------------------------------------------------------------------------- +# 4. subscribe to PENDING → provider reject → REJECTED +# --------------------------------------------------------------------------- + +def test_subscribe_pending_provider_reject_becomes_rejected(tmp_journal): + """PENDING → provider reject → state REJECTED.""" + priv_p, pub_p, did_p = _make_member(tmp_journal) + priv_s, pub_s, did_s = _make_member(tmp_journal) + + offer = offer_service( + tmp_journal, priv_p, did_p, + name="PendingSvc", kind="api", endpoint="/pending", + approval_mode="pending", + ) + sub = subscribe(tmp_journal, priv_s, did_s, offer.service_id) + + reject_subscription(tmp_journal, priv_p, did_p, sub.subscription_id) + + state = subscription_state(tmp_journal, sub.subscription_id) + assert state == SubscriptionState.REJECTED, ( + f"After provider rejection, state must be REJECTED, got {state}" + ) + + +# --------------------------------------------------------------------------- +# 5. Non-provider approve → PermissionError +# --------------------------------------------------------------------------- + +def test_non_provider_approve_raises_permission_error(tmp_journal): + """A non-provider cannot approve a subscription — must raise PermissionError.""" + priv_p, pub_p, did_p = _make_member(tmp_journal) + priv_s, pub_s, did_s = _make_member(tmp_journal) + # A third party attacker + priv_att, pub_att, did_att = _make_member(tmp_journal) + + offer = offer_service( + tmp_journal, priv_p, did_p, + name="SecureSvc", kind="api", endpoint="/secure", + approval_mode="pending", + ) + sub = subscribe(tmp_journal, priv_s, did_s, offer.service_id) + + with pytest.raises(PermissionError): + approve_subscription(tmp_journal, priv_att, did_att, sub.subscription_id) + + +def test_approve_entry_by_non_provider_does_not_approve(tmp_journal): + """Defense-in-depth: an approve entry authored by a non-provider must NOT flip state. + + Even if such an entry were somehow written to the log, subscription_state() + must not count it. We simulate this by directly journal.append()-ing a + SERVICE_APPROVE entry authored by an attacker and verifying the state stays PENDING. + """ + priv_p, pub_p, did_p = _make_member(tmp_journal) + priv_s, pub_s, did_s = _make_member(tmp_journal) + priv_att, pub_att, did_att = _make_member(tmp_journal) + + offer = offer_service( + tmp_journal, priv_p, did_p, + name="SecureSvc", kind="api", endpoint="/secure", + approval_mode="pending", + ) + sub = subscribe(tmp_journal, priv_s, did_s, offer.service_id) + + # Directly forge an approve entry authored by the attacker (not the provider) + forge_payload = { + "subscription_id": sub.subscription_id, + "service_id": offer.service_id, + "approver": did_att, + "approved_at": now_rfc3339(), + } + forge_sig = sign(priv_att, canonical_bytes(forge_payload)) + tmp_journal.append( + op=Op.SERVICE_APPROVE, + payload_type="ServiceApprove", + payload=forge_payload, + author=did_att, # NOT the provider + sig=forge_sig, + author_pubkey_hex=pub_att.hex(), + ) + + # State must still be PENDING — the forged entry must be ignored + state = subscription_state(tmp_journal, sub.subscription_id) + assert state == SubscriptionState.PENDING, ( + f"A non-provider SERVICE_APPROVE must NOT change state; got {state}" + ) + + +# --------------------------------------------------------------------------- +# 6. revoke_offer by non-provider → PermissionError; by provider → offer gone +# --------------------------------------------------------------------------- + +def test_revoke_offer_by_non_provider_raises(tmp_journal): + """A non-provider cannot revoke an offer — must raise PermissionError.""" + priv_p, pub_p, did_p = _make_member(tmp_journal) + priv_other, pub_other, did_other = _make_member(tmp_journal) + + offer = offer_service( + tmp_journal, priv_p, did_p, + name="RevokeMe", kind="api", endpoint="/r", + ) + + with pytest.raises(PermissionError): + revoke_offer(tmp_journal, priv_other, did_other, offer.service_id) + + +def test_revoke_offer_by_provider_removes_from_catalog(tmp_journal): + """Provider revokes their own offer → _get_offers no longer includes it.""" + priv_p, pub_p, did_p = _make_member(tmp_journal) + + offer = offer_service( + tmp_journal, priv_p, did_p, + name="Ephemeral", kind="api", endpoint="/ephemeral", + ) + assert len(_get_offers(tmp_journal)) == 1 + + revoke_offer(tmp_journal, priv_p, did_p, offer.service_id) + assert len(_get_offers(tmp_journal)) == 0 + + +def test_subscribe_to_revoked_offer_fails(tmp_journal): + """Subscribing to a revoked offer must raise ValueError.""" + priv_p, pub_p, did_p = _make_member(tmp_journal) + priv_s, pub_s, did_s = _make_member(tmp_journal) + + offer = offer_service( + tmp_journal, priv_p, did_p, + name="Gone", kind="api", endpoint="/gone", + ) + revoke_offer(tmp_journal, priv_p, did_p, offer.service_id) + + with pytest.raises(ValueError): + subscribe(tmp_journal, priv_s, did_s, offer.service_id) + + +# --------------------------------------------------------------------------- +# 7. subscribe by OBSERVED (non-member) → PermissionError +# --------------------------------------------------------------------------- + +def test_subscribe_by_observed_raises_permission_error(tmp_journal): + """An OBSERVED (non-member) node cannot subscribe — must raise PermissionError.""" + priv_p, pub_p, did_p = _make_member(tmp_journal) + priv_obs, pub_obs, did_obs = _make_observed(tmp_journal) + + offer = offer_service( + tmp_journal, priv_p, did_p, + name="MemberOnly", kind="api", endpoint="/members", + ) + + with pytest.raises(PermissionError): + subscribe(tmp_journal, priv_obs, did_obs, offer.service_id) + + +# --------------------------------------------------------------------------- +# 8. ingest_offer valid sig → ingested; tampered/forged sig → ValueError +# --------------------------------------------------------------------------- + +def _build_signed_offer(priv: bytes, did: str, **kwargs) -> tuple: + """Build a signed ServiceOffer and return (offer_obj, provider_pubkey_hex).""" + from cryptography.hazmat.primitives.asymmetric import ed25519 as _ed25519 + from cryptography.hazmat.primitives import serialization + + priv_key = _ed25519.Ed25519PrivateKey.from_private_bytes(priv) + pub_key = priv_key.public_key() + pub_hex = pub_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ).hex() + + service_id = os.urandom(16).hex() + offer = ServiceOffer( + service_id=service_id, + provider=did, + name=kwargs.get("name", "RemoteSvc"), + kind=kwargs.get("kind", "api"), + endpoint=kwargs.get("endpoint", "http://remote/api"), + approval_mode=ApprovalMode(kwargs.get("approval_mode", "auto")), + description=kwargs.get("description", ""), + ) + full = offer.model_dump() + payload = {k: v for k, v in full.items() if k not in ("sig", "signer_did")} + sig_hex = sign(priv, canonical_bytes(payload)) + signed_offer = ServiceOffer(**{**payload, "sig": sig_hex, "signer_did": did}) + return signed_offer, pub_hex + + +def test_ingest_offer_valid_sig_succeeds(tmp_journal): + """ingest_offer with a valid provider sig must be accepted into the catalog.""" + # Remote provider keypair (unknown to local journal) + priv_remote, pub_remote = generate_keypair() + did_remote = did_from_pubkey(pub_remote) + + signed_offer, pub_hex = _build_signed_offer(priv_remote, did_remote, name="Remote") + + ingest_offer(tmp_journal, signed_offer, pub_hex) + + # The offer should appear in the journal + offers = [] + for entry in tmp_journal.iter_entries(): + if entry.op == Op.SERVICE_OFFER: + offers.append(entry.payload) + assert any(o.get("service_id") == signed_offer.service_id for o in offers), ( + "Ingested offer must appear in the journal" + ) + + +def test_ingest_offer_wrong_pubkey_raises_value_error(tmp_journal): + """ingest_offer with a mismatched pubkey must raise ValueError.""" + priv_remote, pub_remote = generate_keypair() + did_remote = did_from_pubkey(pub_remote) + + signed_offer, _correct_pub_hex = _build_signed_offer(priv_remote, did_remote) + + # Use a completely different keypair's pubkey + _, wrong_pub = generate_keypair() + wrong_pub_hex = wrong_pub.hex() + + with pytest.raises(ValueError, match="signature verification failed"): + ingest_offer(tmp_journal, signed_offer, wrong_pub_hex) + + +def test_ingest_offer_tampered_sig_raises_value_error(tmp_journal): + """ingest_offer with a tampered (wrong) sig must raise ValueError.""" + priv_remote, pub_remote = generate_keypair() + did_remote = did_from_pubkey(pub_remote) + + signed_offer, pub_hex = _build_signed_offer(priv_remote, did_remote) + + # Tamper the sig by flipping a character + bad_sig = "0" * 128 # all zeros — definitely invalid + tampered = ServiceOffer(**{**signed_offer.model_dump(), "sig": bad_sig}) + + with pytest.raises(ValueError, match="signature verification failed"): + ingest_offer(tmp_journal, tampered, pub_hex) + + +def test_ingest_offer_missing_sig_raises_value_error(tmp_journal): + """ingest_offer with no sig must raise ValueError immediately.""" + priv_remote, pub_remote = generate_keypair() + did_remote = did_from_pubkey(pub_remote) + + # Build offer without a sig + offer_no_sig = ServiceOffer( + service_id=os.urandom(16).hex(), + provider=did_remote, + name="Unsigned", + kind="api", + endpoint="/unsigned", + sig=None, + ) + + with pytest.raises(ValueError, match="no signature"): + ingest_offer(tmp_journal, offer_no_sig, pub_remote.hex()) + + +def test_ingest_offer_forged_provider_did_raises(tmp_journal): + """Forging a provider DID (signing with wrong key) must be rejected.""" + priv_legit, pub_legit = generate_keypair() + did_legit = did_from_pubkey(pub_legit) + + priv_attacker, pub_attacker = generate_keypair() + did_attacker = did_from_pubkey(pub_attacker) + + # Attacker signs a payload claiming to be did_legit + service_id = os.urandom(16).hex() + offer = ServiceOffer( + service_id=service_id, + provider=did_legit, # claims to be the legit provider + name="Forged", + kind="api", + endpoint="/forged", + ) + full = offer.model_dump() + payload = {k: v for k, v in full.items() if k not in ("sig", "signer_did")} + # Attacker signs with THEIR key + bad_sig = sign(priv_attacker, canonical_bytes(payload)) + forged_offer = ServiceOffer(**{**payload, "sig": bad_sig, "signer_did": did_attacker}) + + # Verifying against the LEGIT provider's pubkey must fail + with pytest.raises(ValueError, match="signature verification failed"): + ingest_offer(tmp_journal, forged_offer, pub_legit.hex()) + + +# --------------------------------------------------------------------------- +# 9. Comprehensive: reject then non-provider-approve does not override +# --------------------------------------------------------------------------- + +def test_reject_then_non_provider_approve_stays_rejected(tmp_journal): + """Once rejected by the provider, a non-provider approve must not override.""" + priv_p, pub_p, did_p = _make_member(tmp_journal) + priv_s, pub_s, did_s = _make_member(tmp_journal) + priv_att, pub_att, did_att = _make_member(tmp_journal) + + offer = offer_service( + tmp_journal, priv_p, did_p, + name="SecureSvc2", kind="api", endpoint="/s2", + approval_mode="pending", + ) + sub = subscribe(tmp_journal, priv_s, did_s, offer.service_id) + + # Provider rejects + reject_subscription(tmp_journal, priv_p, did_p, sub.subscription_id) + + # Attacker writes a SERVICE_APPROVE directly + forge_payload = { + "subscription_id": sub.subscription_id, + "service_id": offer.service_id, + "approver": did_att, + "approved_at": now_rfc3339(), + } + forge_sig = sign(priv_att, canonical_bytes(forge_payload)) + tmp_journal.append( + op=Op.SERVICE_APPROVE, + payload_type="ServiceApprove", + payload=forge_payload, + author=did_att, + sig=forge_sig, + author_pubkey_hex=pub_att.hex(), + ) + + # State must remain REJECTED (revoke takes priority, and the forged approve is ignored) + state = subscription_state(tmp_journal, sub.subscription_id) + assert state == SubscriptionState.REJECTED, ( + f"After provider reject + forged approve, state must be REJECTED, got {state}" + ) + + +# --------------------------------------------------------------------------- +# 10. _get_offer returns None for unknown service_id +# --------------------------------------------------------------------------- + +def test_get_offer_unknown_returns_none(tmp_journal): + """_get_offer for an unknown service_id must return None.""" + result = _get_offer(tmp_journal, "nonexistent-service-id") + assert result is None + + +# --------------------------------------------------------------------------- +# 11. subscription_state raises for unknown subscription_id +# --------------------------------------------------------------------------- + +def test_subscription_state_unknown_raises(tmp_journal): + """subscription_state for an unknown subscription_id must raise ValueError.""" + with pytest.raises(ValueError, match="not found"): + subscription_state(tmp_journal, "nonexistent-sub-id") diff --git a/packages/secubox-annuaire/www/annuaire/index.html b/packages/secubox-annuaire/www/annuaire/index.html index 9cbaa170..2ab4c5a6 100644 --- a/packages/secubox-annuaire/www/annuaire/index.html +++ b/packages/secubox-annuaire/www/annuaire/index.html @@ -40,6 +40,20 @@

Trust log

#optypeauthorwhen
Loading…
+ +

Services

+

Non-revoked service offers published on this node. Subscribe requires a MEMBER identity and JWT.

+ + + +
NameKindProviderApprovalAction
Loading…
+
+ +

My subscriptions

+ + + +
ServiceSubscription IDState
Loading…