gabriel / musehub public
musehub_attestations.py python
739 lines 29.2 KB
Raw
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 9 days ago
1 """Attestation service — claim-type registry, scope, issue, verify, revoke, query.
2
3 Canonical ATTEST message (UTF-8, newline-separated):
4
5 Identity scope (backward-compatible):
6 ATTEST\\n{attester}\\n{subject}\\n{claim}\\n{issued_at_iso}
7
8 Repo / commit scope (scope_ref appended as sixth line):
9 ATTEST\\n{attester}\\n{subject}\\n{claim}\\n{issued_at_iso}\\n{scope_ref}
10
11 The ``ATTEST`` prefix domain-separates this from MSign auth headers
12 (``MUSE-SIGN-V1``) and MPay payment messages (``MPAY``), preventing
13 cross-protocol replay attacks.
14
15 Claim types are validated against the in-memory registry (fast, no I/O) before
16 any DB write. The DB-backed registry (``musehub_attestation_claim_types``) is
17 the authoritative source for runtime additions, deprecations, and metadata schemas.
18 """
19
20 from __future__ import annotations
21
22 import json
23 import logging
24 from collections.abc import Mapping
25 from datetime import datetime, timezone
26 from typing import TypedDict
27
28 from musehub.types.json_types import ReadOnlyJSONObject
29
30 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
31 from muse.core.types import decode_sig, decode_pubkey
32 from sqlalchemy import text
33 from sqlalchemy.ext.asyncio import AsyncSession
34
35 from musehub.core.genesis import compute_attestation_id
36 from musehub.models.musehub import (
37 AttestationBadge,
38 AttestationListResponse,
39 AttestationRequest,
40 AttestationResponse,
41 ClaimTypeListResponse,
42 ClaimTypeRecord,
43 )
44
45 logger = logging.getLogger(__name__)
46
47 class _ClaimTypeSeed(TypedDict):
48 """Shape of each in-memory seed entry in ``_CLAIM_TYPES``."""
49 type_key: str
50 category: str
51 label: str
52 description: str
53 valid_scopes: list[str]
54
55
56 class ScopeRef(TypedDict):
57 """Parsed scope reference returned by ``parse_scope_ref``."""
58 handle: str
59 repo_slug: str | None
60 commit_id: str | None
61
62
63 # ---------------------------------------------------------------------------
64 # In-memory claim type registry (seed data; DB is authoritative at runtime)
65 # ---------------------------------------------------------------------------
66
67 _CLAIM_TYPES: dict[str, _ClaimTypeSeed] = {
68 ct["type_key"]: ct
69 for ct in [
70 {"type_key": "human", "category": "identity", "label": "Human", "description": "Attester vouches subject is a verified human.", "valid_scopes": ["identity"]},
71 {"type_key": "org", "category": "identity", "label": "Organisation", "description": "Attester vouches subject is a legitimate organisation.", "valid_scopes": ["identity"]},
72 {"type_key": "agent", "category": "identity", "label": "Agent", "description": "Attester vouches subject is a trustworthy agent.", "valid_scopes": ["identity"]},
73 {"type_key": "spawned-by", "category": "trust", "label": "Spawned By", "description": "Subject agent was spawned by attester.", "valid_scopes": ["identity"]},
74 {"type_key": "delegate", "category": "trust", "label": "Delegate", "description": "Attester delegated authority to subject.", "valid_scopes": ["identity"]},
75 {"type_key": "trusted", "category": "trust", "label": "Trusted", "description": "Attester generally trusts subject.", "valid_scopes": ["identity"]},
76 {"type_key": "collab", "category": "collab", "label": "Collaborator", "description": "Attester and subject collaborated.", "valid_scopes": ["identity", "repo", "commit"]},
77 {"type_key": "co-author", "category": "collab", "label": "Co-author", "description": "Subject co-authored something with attester.", "valid_scopes": ["identity", "repo", "commit"]},
78 {"type_key": "contractor", "category": "collab", "label": "Contractor", "description": "Subject performed contracted work for attester.", "valid_scopes": ["identity"]},
79 {"type_key": "code:reviewed", "category": "code", "label": "Code Reviewed", "description": "Attester reviewed subject's code.", "valid_scopes": ["commit", "repo"]},
80 {"type_key": "code:approved", "category": "code", "label": "Code Approved", "description": "Attester approved a specific code delivery.", "valid_scopes": ["commit", "repo"]},
81 {"type_key": "deploy:approved","category": "code", "label": "Deploy Approved", "description": "Attester approved a deployment at this commit.", "valid_scopes": ["commit"]},
82 {"type_key": "stems:verified", "category": "music", "label": "Stems Verified", "description": "Attester verified the authenticity of subject's stems.", "valid_scopes": ["identity", "commit"]},
83 {"type_key": "mix:approved", "category": "music", "label": "Mix Approved", "description": "Attester approved a mix by subject.", "valid_scopes": ["identity", "commit"]},
84 {"type_key": "midi:generated", "category": "music", "label": "MIDI Generated", "description": "Attester confirms subject generated the MIDI.", "valid_scopes": ["identity", "commit"]},
85 {"type_key": "master:approved","category": "music", "label": "Master Approved", "description": "Attester approved a master by subject.", "valid_scopes": ["identity", "commit"]},
86 {"type_key": "skill:verified", "category": "skill", "label": "Skill Verified", "description": "Attester verified a declared skill of subject.", "valid_scopes": ["identity"]},
87 ]
88 }
89
90 # Module-level set of deprecated type keys — extended at runtime via deprecate_claim_type().
91 # Seeded with a test sentinel so unit tests can exercise the deprecated path without DB.
92 _DEPRECATED_TYPES: set[str] = {"__deprecated_test__"}
93
94 _VALID_SCOPES = frozenset({"identity", "repo", "commit"})
95
96
97 # ---------------------------------------------------------------------------
98 # Registry — pure (no I/O)
99 # ---------------------------------------------------------------------------
100
101 def get_claim_type(type_key: str) -> _ClaimTypeSeed:
102 """Return the in-memory claim type record for *type_key*.
103
104 Raises ``ValueError`` with message ``"unknown claim type: {type_key}"`` if
105 the key is not in the registry. Does not check deprecation — call
106 ``validate_claim_for_issue`` before writing.
107 """
108 ct = _CLAIM_TYPES.get(type_key)
109 if ct is None:
110 raise ValueError(f"unknown claim type: '{type_key}'")
111 return ct
112
113
114 def list_claim_types(include_deprecated: bool = False) -> list[_ClaimTypeSeed]:
115 """Return all claim types from the in-memory registry.
116
117 Deprecated types are excluded by default; pass ``include_deprecated=True``
118 to include them.
119 """
120 if include_deprecated:
121 return list(_CLAIM_TYPES.values())
122 return [ct for ct in _CLAIM_TYPES.values() if ct["type_key"] not in _DEPRECATED_TYPES]
123
124
125 def validate_claim_for_issue(type_key: str) -> None:
126 """Raise ``ValueError`` if *type_key* is deprecated or unknown.
127
128 Deprecated check runs first so callers get a precise error message rather
129 than "unknown claim type" for a type that exists but has been retired.
130 """
131 if type_key in _DEPRECATED_TYPES:
132 raise ValueError(f"claim type '{type_key}' is deprecated and cannot be used for new attestations")
133 get_claim_type(type_key) # raises "unknown claim type" if absent
134
135
136 def validate_scope_for_claim(type_key: str, scope: str) -> None:
137 """Raise ``ValueError`` if *scope* is not in the claim type's ``valid_scopes``.
138
139 Example: ``validate_scope_for_claim("human", "commit")`` raises because
140 ``human`` is identity-only.
141 """
142 ct = get_claim_type(type_key)
143 if scope not in ct["valid_scopes"]:
144 raise ValueError(
145 f"'{type_key}' is not valid for scope '{scope}' "
146 f"(valid scopes: {ct['valid_scopes']})"
147 )
148
149
150 def parse_scope_ref(scope: str, scope_ref: str | None) -> ScopeRef:
151 """Parse *scope_ref* into ``{handle, repo_slug, commit_id}``.
152
153 ``scope_ref`` formats:
154 identity → scope_ref is None; handle is the subject itself
155 repo → ``{handle}/{repo_slug}``
156 commit → ``{handle}/{repo_slug}@{sha256:commit_id}``
157
158 Raises ``ValueError`` on malformed input or invalid scope.
159 """
160 if scope not in _VALID_SCOPES:
161 raise ValueError(f"invalid scope '{scope}' — must be one of {sorted(_VALID_SCOPES)}")
162
163 if scope == "identity":
164 return {"handle": scope_ref or "", "repo_slug": None, "commit_id": None}
165
166 if not scope_ref:
167 raise ValueError(f"scope_ref is required for scope '{scope}'")
168
169 if scope == "repo":
170 parts = scope_ref.split("/", 1)
171 if len(parts) != 2:
172 raise ValueError(f"repo scope_ref must be 'handle/repo', got '{scope_ref}'")
173 return {"handle": parts[0], "repo_slug": parts[1], "commit_id": None}
174
175 # commit scope: "handle/repo@sha256:..."
176 if "@" not in scope_ref:
177 raise ValueError(f"commit scope_ref must contain '@', got '{scope_ref}'")
178 slug_part, commit_part = scope_ref.rsplit("@", 1)
179 parts = slug_part.split("/", 1)
180 if len(parts) != 2:
181 raise ValueError(f"commit scope_ref must be 'handle/repo@commit_id', got '{scope_ref}'")
182 return {"handle": parts[0], "repo_slug": parts[1], "commit_id": commit_part}
183
184
185 # ---------------------------------------------------------------------------
186 # Canonical message
187 # ---------------------------------------------------------------------------
188
189 def build_canonical_message(
190 attester: str,
191 subject: str,
192 claim: str,
193 issued_at_iso: str,
194 scope: str = "identity",
195 scope_ref: str | None = None,
196 ) -> bytes:
197 """Build the canonical ATTEST message bytes for signature verification.
198
199 Identity scope (backward-compatible with all existing attestations):
200 ATTEST\\n{attester}\\n{subject}\\n{claim}\\n{issued_at_iso}
201
202 Repo/commit scope appends scope_ref as a sixth newline-separated field,
203 binding the attestation cryptographically to the exact target object.
204 """
205 parts = ["ATTEST", attester, subject, claim, issued_at_iso]
206 if scope != "identity" and scope_ref:
207 parts.append(scope_ref)
208 return "\n".join(parts).encode()
209
210
211 # ---------------------------------------------------------------------------
212 # Signature helpers
213 # ---------------------------------------------------------------------------
214
215 def _canonical_message(attester: str, subject: str, claim: str, issued_at_iso: str) -> bytes:
216 """Legacy identity-scope canonical message builder — kept for import compat."""
217 return build_canonical_message(attester, subject, claim, issued_at_iso)
218
219
220 def verify_attestation_signature(
221 attester: str,
222 subject: str,
223 claim: str,
224 issued_at_iso: str,
225 signature: str,
226 attester_public_key: str,
227 scope: str = "identity",
228 scope_ref: str | None = None,
229 ) -> tuple[bool, str]:
230 """Verify an Ed25519 attestation signature.
231
232 Returns ``(True, "")`` on success; ``(False, reason)`` on failure.
233 Accepts both identity-scope (5-line) and scoped (6-line) canonical messages.
234 """
235 if not signature.startswith("ed25519:"):
236 return False, "signature must start with 'ed25519:'"
237 if not attester_public_key.startswith("ed25519:"):
238 return False, "public key must start with 'ed25519:'"
239
240 try:
241 _, sig_bytes = decode_sig(signature)
242 _, key_bytes = decode_pubkey(attester_public_key)
243 pubkey = Ed25519PublicKey.from_public_bytes(key_bytes)
244 msg = build_canonical_message(attester, subject, claim, issued_at_iso, scope, scope_ref)
245 pubkey.verify(sig_bytes, msg)
246 return True, ""
247 except Exception as exc:
248 return False, str(exc)
249
250
251 def _extract_claim_type(claim_json: str) -> str:
252 """Return the ``type`` field value from a claim JSON string."""
253 try:
254 payload = json.loads(claim_json)
255 return payload.get("type", "unknown")
256 except (json.JSONDecodeError, AttributeError):
257 return "unknown"
258
259
260 def _row_to_response(r: Mapping[str, object]) -> AttestationResponse:
261 return AttestationResponse(
262 attestation_id=r["attestation_id"],
263 attester=r["attester"],
264 subject=r["subject"],
265 claim=r["claim"],
266 signature=r["signature"],
267 attester_public_key=r["attester_public_key"],
268 issued_at=r["issued_at"],
269 revoked_at=r.get("revoked_at"),
270 scope=r.get("scope", "identity"),
271 scope_ref=r.get("scope_ref"),
272 repo_id=r.get("repo_id"),
273 commit_id=r.get("commit_id"),
274 expires_at=r.get("expires_at"),
275 )
276
277
278 # ---------------------------------------------------------------------------
279 # Issue
280 # ---------------------------------------------------------------------------
281
282 async def issue_attestation(
283 db: AsyncSession,
284 req: AttestationRequest,
285 ) -> AttestationResponse:
286 """Validate, sign-verify, and persist an attestation.
287
288 Validation order (fail-fast):
289 1. Claim type exists in registry and is not deprecated
290 2. Claim type is valid for the requested scope
291 3. scope_ref parses correctly for the given scope
292 4. Ed25519 signature verifies against the canonical ATTEST message
293 5. Idempotency check — same canonical payload returns existing record
294
295 Raises ``ValueError`` on any validation failure.
296 """
297 claim_type = _extract_claim_type(req.claim)
298 validate_claim_for_issue(claim_type)
299 validate_scope_for_claim(claim_type, req.scope)
300 if req.scope_ref:
301 parse_scope_ref(req.scope, req.scope_ref) # structural validation
302
303 issued_at_iso = req.issued_at.isoformat()
304 ok, reason = verify_attestation_signature(
305 req.attester, req.subject, req.claim, issued_at_iso,
306 req.signature, req.attester_public_key,
307 scope=req.scope, scope_ref=req.scope_ref,
308 )
309 if not ok:
310 raise ValueError(f"Invalid attestation signature: {reason}")
311
312 aid = compute_attestation_id(req.attester, req.subject, req.claim, issued_at_iso)
313
314 existing = (await db.execute(
315 text(
316 "SELECT attestation_id, attester, subject, claim, signature, "
317 "attester_public_key, issued_at, revoked_at, scope, scope_ref, "
318 "repo_id, commit_id, expires_at "
319 "FROM musehub_attestations WHERE attestation_id = :aid"
320 ),
321 {"aid": aid},
322 )).mappings().one_or_none()
323
324 if existing is not None:
325 return _row_to_response(dict(existing))
326
327 from sqlalchemy.exc import IntegrityError
328 try:
329 await db.execute(
330 text(
331 "INSERT INTO musehub_attestations "
332 "(attestation_id, attester, subject, claim, signature, "
333 "attester_public_key, issued_at, scope, scope_ref, repo_id, commit_id, expires_at) "
334 "VALUES (:aid, :attester, :subject, :claim, :sig, :pubkey, :issued_at, "
335 ":scope, :scope_ref, :repo_id, :commit_id, :expires_at)"
336 ),
337 {
338 "aid": aid,
339 "attester": req.attester,
340 "subject": req.subject,
341 "claim": req.claim,
342 "sig": req.signature,
343 "pubkey": req.attester_public_key,
344 "issued_at": req.issued_at,
345 "scope": req.scope,
346 "scope_ref": req.scope_ref,
347 "repo_id": req.repo_id,
348 "commit_id": req.commit_id,
349 "expires_at": req.expires_at,
350 },
351 )
352 await db.commit()
353 except IntegrityError:
354 await db.rollback()
355 row = (await db.execute(
356 text(
357 "SELECT attestation_id, attester, subject, claim, signature, "
358 "attester_public_key, issued_at, revoked_at, scope, scope_ref, "
359 "repo_id, commit_id, expires_at "
360 "FROM musehub_attestations WHERE attestation_id = :aid"
361 ),
362 {"aid": aid},
363 )).mappings().one()
364 return _row_to_response(dict(row))
365
366 return AttestationResponse(
367 attestation_id=aid,
368 attester=req.attester,
369 subject=req.subject,
370 claim=req.claim,
371 signature=req.signature,
372 attester_public_key=req.attester_public_key,
373 issued_at=req.issued_at,
374 revoked_at=None,
375 scope=req.scope,
376 scope_ref=req.scope_ref,
377 repo_id=req.repo_id,
378 commit_id=req.commit_id,
379 expires_at=req.expires_at,
380 )
381
382
383 # ---------------------------------------------------------------------------
384 # Revoke
385 # ---------------------------------------------------------------------------
386
387 async def revoke_attestation(
388 db: AsyncSession,
389 attestation_id: str,
390 revoker: str,
391 ) -> AttestationResponse:
392 """Revoke an attestation. Only the original attester may revoke their own claim.
393
394 Sets ``revoked_at`` to now; never deletes the row (immutable audit trail).
395 Raises ``KeyError`` if not found; ``ValueError`` if wrong revoker.
396 """
397 row = (await db.execute(
398 text(
399 "SELECT attestation_id, attester, subject, claim, signature, "
400 "attester_public_key, issued_at, revoked_at, scope, scope_ref, "
401 "repo_id, commit_id, expires_at "
402 "FROM musehub_attestations WHERE attestation_id = :aid"
403 ),
404 {"aid": attestation_id},
405 )).mappings().one_or_none()
406
407 if row is None:
408 raise KeyError(f"attestation '{attestation_id}' not found")
409
410 if row["attester"] != revoker:
411 raise PermissionError(
412 f"only the attester can revoke this attestation; "
413 f"got '{revoker}'"
414 )
415
416 now = datetime.now(tz=timezone.utc)
417 await db.execute(
418 text("UPDATE musehub_attestations SET revoked_at = :now WHERE attestation_id = :aid"),
419 {"now": now, "aid": attestation_id},
420 )
421 await db.commit()
422
423 result = dict(row)
424 result["revoked_at"] = now
425 return _row_to_response(result)
426
427
428 # ---------------------------------------------------------------------------
429 # Query — subject (identity scope only, for profile page)
430 # ---------------------------------------------------------------------------
431
432 async def get_attestations_for_subject(
433 db: AsyncSession,
434 subject: str,
435 include_revoked: bool = False,
436 include_expired: bool = False,
437 include_all_scopes: bool = False,
438 ) -> AttestationListResponse:
439 """Return attestations about *subject*.
440
441 By default only ``scope='identity'`` rows are returned — repo/commit-scoped
442 attestations appear on their respective repo and commit pages. Pass
443 ``include_all_scopes=True`` (used by the profile UI) to include code/music
444 commit-scoped attestations so all 17 claim types can appear on the profile.
445 Expired attestations (``expires_at < now``) are excluded by default.
446 """
447 clauses = ["subject = :subject"]
448 if not include_all_scopes:
449 clauses.append("scope = 'identity'")
450 if not include_revoked:
451 clauses.append("revoked_at IS NULL")
452 if not include_expired:
453 clauses.append("(expires_at IS NULL OR expires_at > NOW())")
454
455 where = " AND ".join(clauses)
456 rows = await db.execute(
457 text(
458 "SELECT attestation_id, attester, subject, claim, signature, "
459 "attester_public_key, issued_at, revoked_at, scope, scope_ref, "
460 "repo_id, commit_id, expires_at "
461 f"FROM musehub_attestations WHERE {where} "
462 "ORDER BY issued_at DESC"
463 ),
464 {"subject": subject},
465 )
466 items = [_row_to_response(dict(r)) for r in rows.mappings().all()]
467 return AttestationListResponse(subject=subject, attestations=items, total=len(items))
468
469
470 # ---------------------------------------------------------------------------
471 # Query — attester
472 # ---------------------------------------------------------------------------
473
474 async def get_attestations_by_attester(
475 db: AsyncSession,
476 attester: str,
477 include_revoked: bool = False,
478 include_expired: bool = False,
479 ) -> AttestationListResponse:
480 """Return all attestations issued by *attester* across all scopes."""
481 clauses = ["attester = :attester"]
482 if not include_revoked:
483 clauses.append("revoked_at IS NULL")
484 if not include_expired:
485 clauses.append("(expires_at IS NULL OR expires_at > NOW())")
486
487 where = " AND ".join(clauses)
488 rows = await db.execute(
489 text(
490 "SELECT attestation_id, attester, subject, claim, signature, "
491 "attester_public_key, issued_at, revoked_at, scope, scope_ref, "
492 "repo_id, commit_id, expires_at "
493 f"FROM musehub_attestations WHERE {where} "
494 "ORDER BY issued_at DESC"
495 ),
496 {"attester": attester},
497 )
498 items = [_row_to_response(dict(r)) for r in rows.mappings().all()]
499 return AttestationListResponse(subject=attester, attestations=items, total=len(items))
500
501
502 # ---------------------------------------------------------------------------
503 # Query — commit-scoped
504 # ---------------------------------------------------------------------------
505
506 async def get_attestations_for_commit(
507 db: AsyncSession,
508 commit_id: str,
509 include_revoked: bool = False,
510 ) -> AttestationListResponse:
511 """Return all commit-scoped attestations for *commit_id*.
512
513 Uses the ``ix_musehub_attestations_commit_id`` index for O(log n) lookup.
514 """
515 clauses = ["commit_id = :commit_id", "scope = 'commit'"]
516 if not include_revoked:
517 clauses.append("revoked_at IS NULL")
518
519 where = " AND ".join(clauses)
520 rows = await db.execute(
521 text(
522 "SELECT attestation_id, attester, subject, claim, signature, "
523 "attester_public_key, issued_at, revoked_at, scope, scope_ref, "
524 "repo_id, commit_id, expires_at "
525 f"FROM musehub_attestations WHERE {where} "
526 "ORDER BY issued_at DESC"
527 ),
528 {"commit_id": commit_id},
529 )
530 items = [_row_to_response(dict(r)) for r in rows.mappings().all()]
531 return AttestationListResponse(subject=commit_id, attestations=items, total=len(items))
532
533
534 # ---------------------------------------------------------------------------
535 # Query — repo-scoped
536 # ---------------------------------------------------------------------------
537
538 async def get_attestations_for_repo(
539 db: AsyncSession,
540 repo_slug: str,
541 include_revoked: bool = False,
542 ) -> AttestationListResponse:
543 """Return all repo-scoped attestations for *repo_slug* (``handle/repo``)."""
544 clauses = ["subject = :slug", "scope = 'repo'"]
545 if not include_revoked:
546 clauses.append("revoked_at IS NULL")
547
548 where = " AND ".join(clauses)
549 rows = await db.execute(
550 text(
551 "SELECT attestation_id, attester, subject, claim, signature, "
552 "attester_public_key, issued_at, revoked_at, scope, scope_ref, "
553 "repo_id, commit_id, expires_at "
554 f"FROM musehub_attestations WHERE {where} "
555 "ORDER BY issued_at DESC"
556 ),
557 {"slug": repo_slug},
558 )
559 items = [_row_to_response(dict(r)) for r in rows.mappings().all()]
560 return AttestationListResponse(subject=repo_slug, attestations=items, total=len(items))
561
562
563 # ---------------------------------------------------------------------------
564 # Verify stored attestation (key-rotation-safe)
565 # ---------------------------------------------------------------------------
566
567 async def verify_stored_attestation(
568 db: AsyncSession,
569 attestation_id: str,
570 ) -> tuple[bool, str]:
571 """Re-verify a stored attestation using its recorded ``attester_public_key``.
572
573 Key-rotation-safe: uses the public key stored at issuance time, not any
574 current key associated with the attester handle. This means an attestation
575 remains verifiable even after the attester rotates their identity key.
576
577 Returns ``(True, "")`` on success; ``(False, reason)`` on failure.
578 """
579 row = (await db.execute(
580 text(
581 "SELECT attester, subject, claim, signature, attester_public_key, "
582 "issued_at, scope, scope_ref "
583 "FROM musehub_attestations WHERE attestation_id = :aid"
584 ),
585 {"aid": attestation_id},
586 )).mappings().one_or_none()
587
588 if row is None:
589 return False, f"attestation '{attestation_id}' not found"
590
591 issued_at_iso = row["issued_at"].isoformat() if isinstance(row["issued_at"], datetime) else str(row["issued_at"])
592 return verify_attestation_signature(
593 attester=row["attester"],
594 subject=row["subject"],
595 claim=row["claim"],
596 issued_at_iso=issued_at_iso,
597 signature=row["signature"],
598 attester_public_key=row["attester_public_key"],
599 scope=row.get("scope", "identity"),
600 scope_ref=row.get("scope_ref"),
601 )
602
603
604 # ---------------------------------------------------------------------------
605 # Badge (profile card compact view)
606 # ---------------------------------------------------------------------------
607
608 def attestation_to_badge(a: AttestationResponse) -> AttestationBadge:
609 """Extract a compact badge from an attestation record for profile card display."""
610 return AttestationBadge(
611 attestation_id=a.attestation_id,
612 attester=a.attester,
613 subject=a.subject,
614 claim_type=_extract_claim_type(a.claim),
615 claim=a.claim,
616 issued_at=a.issued_at,
617 revoked_at=a.revoked_at,
618 scope=a.scope,
619 scope_ref=a.scope_ref,
620 signature=a.signature,
621 attester_public_key=a.attester_public_key,
622 )
623
624
625 # ---------------------------------------------------------------------------
626 # DB-backed registry (runtime additions / deprecations)
627 # ---------------------------------------------------------------------------
628
629 async def get_claim_type_from_db(
630 db: AsyncSession,
631 type_key: str,
632 ) -> ClaimTypeRecord | None:
633 """Return the DB registry row for *type_key*, or ``None`` if absent."""
634 row = (await db.execute(
635 text(
636 "SELECT type_key, category, label, description, valid_scopes, "
637 "introduced_at, deprecated_at "
638 "FROM musehub_attestation_claim_types WHERE type_key = :key"
639 ),
640 {"key": type_key},
641 )).mappings().one_or_none()
642 if not row:
643 return None
644 return ClaimTypeRecord(
645 type_key=str(row["type_key"]),
646 category=str(row["category"]),
647 label=str(row["label"]),
648 description=str(row["description"]),
649 valid_scopes=list(row["valid_scopes"]),
650 deprecated_at=row.get("deprecated_at"), # type: ignore[arg-type]
651 )
652
653
654 async def list_claim_types_from_db(
655 db: AsyncSession,
656 include_deprecated: bool = False,
657 ) -> ClaimTypeListResponse:
658 """Return all claim types from the DB registry."""
659 where = "" if include_deprecated else "WHERE deprecated_at IS NULL"
660 rows = (await db.execute(
661 text(
662 "SELECT type_key, category, label, description, valid_scopes, deprecated_at "
663 f"FROM musehub_attestation_claim_types {where} ORDER BY category, type_key"
664 )
665 )).mappings().all()
666
667 records = [
668 ClaimTypeRecord(
669 type_key=r["type_key"],
670 category=r["category"],
671 label=r["label"],
672 description=r["description"],
673 valid_scopes=list(r["valid_scopes"]),
674 deprecated_at=r.get("deprecated_at"),
675 )
676 for r in rows
677 ]
678 return ClaimTypeListResponse(claim_types=records, total=len(records))
679
680
681 async def add_claim_type(
682 db: AsyncSession,
683 type_key: str,
684 category: str,
685 label: str,
686 description: str,
687 valid_scopes: list[str],
688 metadata_schema: ReadOnlyJSONObject | None = None,
689 ) -> None:
690 """Register a new claim type in the DB registry.
691
692 Also inserts into the in-memory registry so subsequent in-process calls
693 to ``get_claim_type`` and ``validate_claim_for_issue`` reflect the addition
694 without requiring a service restart.
695 """
696 await db.execute(
697 text(
698 "INSERT INTO musehub_attestation_claim_types "
699 "(type_key, category, label, description, valid_scopes, metadata_schema, introduced_at) "
700 "VALUES (:key, :cat, :label, :desc, :scopes, :schema, NOW()) "
701 "ON CONFLICT (type_key) DO NOTHING"
702 ),
703 {
704 "key": type_key,
705 "cat": category,
706 "label": label,
707 "desc": description,
708 "scopes": valid_scopes,
709 "schema": metadata_schema,
710 },
711 )
712 await db.commit()
713 # Sync in-memory registry
714 _CLAIM_TYPES[type_key] = {
715 "type_key": type_key,
716 "category": category,
717 "label": label,
718 "description": description,
719 "valid_scopes": valid_scopes,
720 }
721
722
723 async def deprecate_claim_type(db: AsyncSession, type_key: str) -> None:
724 """Soft-deprecate a claim type. Existing attestations are unaffected.
725
726 New attestations with this type will be rejected by ``validate_claim_for_issue``.
727 Updates both the DB record and the in-memory deprecated set so the change
728 takes effect immediately in the current process.
729 """
730 now = datetime.now(tz=timezone.utc)
731 await db.execute(
732 text(
733 "UPDATE musehub_attestation_claim_types SET deprecated_at = :now "
734 "WHERE type_key = :key AND deprecated_at IS NULL"
735 ),
736 {"now": now, "key": type_key},
737 )
738 await db.commit()
739 _DEPRECATED_TYPES.add(type_key)
File History 13 commits
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 9 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 12 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 34 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago