feat(annuaire): service offers + subscription verbs/API/UI + pull federation

- 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.
This commit is contained in:
CyberMind-FR 2026-06-30 08:14:32 +02:00
parent 021710f01c
commit 0d77b64f6b
5 changed files with 1330 additions and 46 deletions

View File

@ -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

View File

@ -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=<subscriber_did> subscriptions by this subscriber
?pending_for=<provider_did> 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 <base_url>/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

View File

@ -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 <devel@cybermind.fr> 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,

View File

@ -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")

View File

@ -40,6 +40,20 @@
<h2>Trust log</h2>
<table><thead><tr><th>#</th><th>op</th><th>type</th><th>author</th><th>when</th></tr></thead>
<tbody id="log"><tr><td colspan="5" class="empty">Loading…</td></tr></tbody></table>
<h2>Services</h2>
<p class="mono" style="margin:4px 0 8px">Non-revoked service offers published on this node. Subscribe requires a MEMBER identity and JWT.</p>
<table>
<thead><tr><th>Name</th><th>Kind</th><th>Provider</th><th>Approval</th><th>Action</th></tr></thead>
<tbody id="svc-list"><tr><td colspan="5" class="empty">Loading…</td></tr></tbody>
</table>
<div id="svc-msg" style="font-size:12px;margin:6px 0;color:var(--cyber-cyan,#00d4ff)"></div>
<h2>My subscriptions</h2>
<table>
<thead><tr><th>Service</th><th>Subscription ID</th><th>State</th></tr></thead>
<tbody id="sub-list"><tr><td colspan="3" class="empty">Loading…</td></tr></tbody>
</table>
</main>
<script>
@ -47,6 +61,60 @@
async function j(p){try{const r=await fetch(API+p);if(!r.ok)throw 0;return await r.json();}catch(e){return null;}}
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':s;return d.innerHTML;}
function shortdid(s){s=String(s||'');return s.length>22?s.slice(0,14)+'…'+s.slice(-5):s;}
function stateColor(st){
const m={approved:'var(--matrix-green,#00ff41)',pending:'var(--gold-hermetic,#c9a84c)',
rejected:'var(--cinnabar,#e63946)',revoked:'var(--text-muted,#6b6b7a)'};
return m[st]||'inherit';
}
async function subscribe(serviceId){
const msg=document.getElementById('svc-msg');
msg.textContent='';
const subDid=prompt('Your subscriber did:plc (leave blank to skip):');
if(!subDid||!subDid.startsWith('did:plc:')){msg.textContent='Subscribe cancelled (no valid DID entered).';return;}
const subPriv=prompt('Your raw Ed25519 private key hex (32 bytes = 64 hex chars):');
if(!subPriv||subPriv.length!==64){msg.textContent='Subscribe cancelled (invalid key length).';return;}
try{
const r=await fetch(API+'/service/'+serviceId+'/subscribe',{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({subscriber_did:subDid,subscriber_priv_hex:subPriv})
});
const data=await r.json();
if(!r.ok){msg.textContent='Error: '+(data.detail||JSON.stringify(data));return;}
msg.textContent='Subscribed — state: '+(data.state||'?')+' (subscription_id: '+shortdid(data.subscription_id||'')+')';
loadSubscriptions(subDid);
}catch(e){msg.textContent='Error: '+e;}
}
async function loadServices(){
const tb=document.getElementById('svc-list');
const data=await j('/services');
const svcs=data&&Array.isArray(data.services)?data.services:[];
if(!svcs.length){tb.innerHTML='<tr><td colspan="5" class="empty">No service offers yet.</td></tr>';return;}
tb.innerHTML=svcs.map(s=>`<tr>
<td>${esc(s.name||'')}</td>
<td><span class="op">${esc(s.kind||'')}</span></td>
<td class="mono" title="${esc(s.provider)}">${esc(shortdid(s.provider))}</td>
<td><span style="color:${s.approval_mode==='auto'?'var(--matrix-green,#00ff41)':'var(--gold-hermetic,#c9a84c)'}">${esc(s.approval_mode||'')}</span></td>
<td><button style="font-size:11px;padding:3px 10px;cursor:pointer;background:var(--panel,#13131c);border:1px solid var(--line,#2a2a3a);color:var(--cyber-cyan,#00d4ff);border-radius:6px"
onclick="subscribe('${esc(s.service_id||'')}')">Subscribe</button></td>
</tr>`).join('');
}
async function loadSubscriptions(filterDid){
const tb=document.getElementById('sub-list');
const params=filterDid?'?mine='+encodeURIComponent(filterDid):'';
const data=await j('/subscriptions'+params);
const subs=data&&Array.isArray(data.subscriptions)?data.subscriptions:[];
if(!subs.length){tb.innerHTML='<tr><td colspan="3" class="empty">No subscriptions'+(filterDid?' for this DID':'')+' yet.</td></tr>';return;}
tb.innerHTML=subs.map(s=>`<tr>
<td class="mono">${esc(shortdid(s.service_id))}</td>
<td class="mono">${esc(shortdid(s.subscription_id))}</td>
<td><span style="color:${stateColor(s.state)};font-weight:600">${esc(s.state||'?')}</span></td>
</tr>`).join('');
}
async function load(){
const st=await j('/status');
if(st){
@ -65,6 +133,8 @@
<td>${esc(e.height)}</td><td><span class="op">${esc(e.op||(e.payload&&e.payload_type)||'?')}</span></td>
<td>${esc(e.payload_type||'')}</td><td class="mono" title="${esc(e.author)}">${esc(shortdid(e.author))}</td>
<td class="mono">${esc((e.ts||e.timestamp||'').toString().replace('T',' ').slice(0,19))}</td></tr>`).join('');
await loadServices();
await loadSubscriptions();
}
document.addEventListener('DOMContentLoaded',load);
setInterval(load,15000);