Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e5c6ea6
merge indy and webvh issuer dids for askar-anoncreds wallets
PatStLouis Dec 15, 2025
9d3c5f4
Add webhooks processing for anoncreds endpoints
PatStLouis Jan 7, 2026
70211fd
fix revocation unit test
PatStLouis Jan 7, 2026
6a1c914
fix unit test
PatStLouis Jan 7, 2026
46e7472
update schema id regex to support anoncreds
PatStLouis Jan 7, 2026
8e37008
fix linting
PatStLouis Jan 7, 2026
09e51b0
fix cred def storage
PatStLouis Jan 7, 2026
fa00a60
fix tests
PatStLouis Jan 7, 2026
b06ce54
add rev reg size to cred def record
PatStLouis Jan 7, 2026
9b61452
remove filter form option
PatStLouis Jan 7, 2026
5ffe743
Update services/tenant-ui/frontend/src/components/issuance/schemas/Cr…
PatStLouis Jan 7, 2026
5454957
Update services/tenant-ui/frontend/src/components/issuance/credential…
PatStLouis Jan 7, 2026
f934e44
Update services/tenant-ui/frontend/src/views/Identifiers.vue
PatStLouis Jan 7, 2026
dc09be4
Update services/tenant-ui/frontend/src/views/Identifiers.vue
PatStLouis Jan 7, 2026
406dd53
Update services/tenant-ui/frontend/src/components/issuance/schemas/Cr…
PatStLouis Jan 7, 2026
71e6295
Update services/tenant-ui/frontend/src/components/issuance/credential…
PatStLouis Jan 7, 2026
8f9fa46
Update services/tenant-ui/frontend/src/components/issuance/schemas/Sc…
PatStLouis Jan 7, 2026
a06ccfe
Update services/tenant-ui/frontend/src/components/issuance/credential…
PatStLouis Jan 7, 2026
b74524f
Update services/tenant-ui/frontend/src/store/governanceStore.ts
PatStLouis Jan 7, 2026
9fdab42
Update services/tenant-ui/frontend/src/store/governanceStore.ts
PatStLouis Jan 7, 2026
a18dfeb
Update services/tenant-ui/frontend/src/store/issuerStore.ts
PatStLouis Jan 7, 2026
34c14b5
Update services/tenant-ui/frontend/src/store/governanceStore.ts
PatStLouis Jan 7, 2026
a18fa0c
Fix function name typo
PatStLouis Jan 7, 2026
c066b5e
fix formatting
PatStLouis Jan 7, 2026
27cc21c
implement pr fix, improved error handling, replacing fixed delays
PatStLouis Jan 8, 2026
9fb6775
simplify cred def storage functions
PatStLouis Jan 8, 2026
2c568ae
simplify cred def events storage
PatStLouis Jan 8, 2026
f74d4c7
fix tests
PatStLouis Jan 8, 2026
745ee7f
remove unused code
PatStLouis Jan 8, 2026
dd4d6ec
fix schema event import
PatStLouis Jan 9, 2026
985de0e
streamline storage services
PatStLouis Jan 12, 2026
a394bac
fix innkeeper plugin
PatStLouis Jan 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
from acapy_agent.storage.error import StorageNotFoundError
from aiohttp import web
from aiohttp_apispec import docs, match_info_schema, response_schema
from connections.v1_0.routes import (ConnectionsConnIdMatchInfoSchema,
InvitationResultSchema)
from connections.v1_0.routes import (
ConnectionsConnIdMatchInfoSchema,
InvitationResultSchema,
)
from marshmallow import fields

LOGGER = logging.getLogger(__name__)


class InvitationResponseSchema(InvitationResultSchema):
"""Response schema for a previous connection invitation."""

Expand All @@ -22,6 +25,7 @@ class InvitationResponseSchema(InvitationResultSchema):
metadata={"description": "Optional alias for the connection", "example": "Bob"},
)


@docs(tags=["connection"], summary="Fetch connection invitation")
@match_info_schema(ConnectionsConnIdMatchInfoSchema())
@response_schema(InvitationResponseSchema(), 200, description="")
Expand Down Expand Up @@ -71,13 +75,13 @@ async def connections_invitation(request: web.BaseRequest):
except BaseModelError as err:
raise web.HTTPBadRequest(reason=err.roll_up) from err

invitation_url = invitation.to_url(base_url) # Let's see what to_url produces
invitation_url = invitation.to_url(base_url) # Let's see what to_url produces

# Always prepend base_url if to_url returns only query params
if invitation_url.startswith("?"): # Check if to_url returned only query params
if invitation_url.startswith("?"): # Check if to_url returned only query params
constructed_invitation_url = f"{base_url}{invitation_url}"
else:
constructed_invitation_url = invitation_url # Assume to_url worked correctly
constructed_invitation_url = invitation_url # Assume to_url worked correctly

result = {
"connection_id": connection_id,
Expand Down Expand Up @@ -106,4 +110,4 @@ async def register(app: web.Application):


def post_process_routes(app: web.Application):
pass
pass
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import re
from typing import Optional

from acapy_agent.core.event_bus import EventBus, Event
Expand All @@ -8,7 +9,10 @@
from .models import CredDefStorageRecord

from acapy_agent.messaging.credential_definitions.util import (
EVENT_LISTENER_PATTERN as CREDDEF_EVENT_LISTENER_PATTERN,
EVENT_LISTENER_PATTERN as INDY_CREDDEF_EVENT_PATTERN,
)
from acapy_agent.anoncreds.events import (
CRED_DEF_FINISHED_EVENT as ANONCREDS_CREDDEF_FINISHED_EVENT,
)

LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -61,20 +65,85 @@ async def list_items(
self.logger.info(f"< list_items({tag_filter}, {post_filter}): {len(records)}")
return records

def _is_anoncreds_wallet(self, profile: Profile) -> bool:
"""Check if the wallet is an anoncreds wallet."""
wallet_type = profile.settings.get("wallet.type", "askar")
return wallet_type in ("askar-anoncreds", "kanon-anoncreds")

async def _fetch_tag(
self,
profile: Profile,
cred_def_id: str,
) -> Optional[str]:
"""Fetch tag from registry for AnonCreds credential definitions.

AnonCreds events never include tag in the event payload, so we must fetch it from the registry.
This function is only called for AnonCreds credential definitions.

Returns tag from registry, or None if fetch fails.
"""
from acapy_agent.anoncreds.registry import AnonCredsRegistry
from acapy_agent.anoncreds.base import AnonCredsResolutionError

try:
anoncreds_registry = profile.inject(AnonCredsRegistry)
cred_def_result = await anoncreds_registry.get_credential_definition(
profile, cred_def_id
)

cred_def = cred_def_result.credential_definition
tag = cred_def.tag or "default"

self.logger.debug(f"Fetched tag from registry: {tag}")
return tag
except AnonCredsResolutionError:
# Registry doesn't support this identifier
self.logger.error(
f"Registry could not resolve credential definition: {cred_def_id}"
)
return None
except Exception as err:
# Other errors from registry (e.g., network issues, injection errors)
self.logger.error(
f"Error fetching credential definition from registry: {err}"
)
return None

async def _create_storage_record(
self, profile: Profile, data: dict
) -> CredDefStorageRecord:
"""Create and save a credential definition storage record."""
rec: CredDefStorageRecord = CredDefStorageRecord.deserialize(data)
self.logger.debug(f"cred_def_storage_rec = {rec}")
async with profile.session() as session:
await rec.save(session, reason="New cred def storage record")
return rec

async def add_item(self, profile: Profile, data: dict):
self.logger.info(f"> add_item({data})")
# check if
cred_def_id = data["cred_def_id"]
cred_def_id = data.get("cred_def_id")
if not cred_def_id:
raise ValueError("cred_def_id is required in data")

# Early return if record already exists
rec = await self.read_item(profile, cred_def_id)
if not rec:
try:
rec: CredDefStorageRecord = CredDefStorageRecord.deserialize(data)
self.logger.debug(f"cred_def_storage_rec = {rec}")
async with profile.session() as session:
await rec.save(session, reason="New cred def storage record")
except Exception as err:
self.logger.error("Error adding cred def storage record.", err)
raise err
if rec:
self.logger.info(f"< add_item({cred_def_id}): {rec}")
return rec

# Fetch tag from registry for AnonCreds credential definitions
# AnonCreds events never include tag, so we must fetch it
if self._is_anoncreds_wallet(profile):
tag = await self._fetch_tag(profile, cred_def_id)
if tag:
data["tag"] = tag

# Create and save the storage record
try:
rec = await self._create_storage_record(profile, data)
except Exception as err:
self.logger.error("Error adding cred def storage record.", err)
raise err

self.logger.info(f"< add_item({cred_def_id}): {rec}")
return rec
Expand Down Expand Up @@ -105,15 +174,66 @@ async def remove_item(self, profile: Profile, cred_def_id: str):


def subscribe(bus: EventBus):
bus.subscribe(CREDDEF_EVENT_LISTENER_PATTERN, creddef_event_handler)
# Subscribe to both Indy and AnonCreds credential definition events
bus.subscribe(INDY_CREDDEF_EVENT_PATTERN, creddef_event_handler)
# Explicitly compile as literal pattern to ensure it's a Pattern object, not a string
bus.subscribe(
re.compile(re.escape(ANONCREDS_CREDDEF_FINISHED_EVENT)), creddef_event_handler
)


def _normalize_creddef_event_payload(event: Event) -> dict:
"""Normalize credential definition event payload from either Indy (dict with context) or AnonCreds (NamedTuple) format.

AnonCreds events use CredDefFinishedPayload NamedTuple (not a dict), so we check for
NamedTuple attributes and convert to a dict format for unified processing.
"""
payload = event.payload

# Check if it's an AnonCreds event (NamedTuple)
# AnonCreds events are CredDefFinishedPayload NamedTuples, not dicts
if hasattr(payload, "schema_id") and hasattr(payload, "cred_def_id"):
# AnonCreds event: CredDefFinishedPayload NamedTuple
rev_reg_size = payload.max_cred_num if payload.support_revocation else None
return {
"cred_def_id": payload.cred_def_id,
"schema_id": payload.schema_id,
"tag": None, # NEVER in AnonCreds event, must be fetched from registry
"support_revocation": payload.support_revocation,
"rev_reg_size": rev_reg_size, # Always set (from max_cred_num)
"issuer_id": payload.issuer_id,
"options": payload.options, # Preserve for any other fields
}
elif isinstance(payload, dict) and "context" in payload:
# Indy event: dict with "context" key
context = payload["context"]
return {
"cred_def_id": context.get("cred_def_id"),
"schema_id": context.get("schema_id"),
"tag": context.get("tag"),
"support_revocation": context.get("support_revocation", False),
"rev_reg_size": context.get("rev_reg_size"),
"issuer_did": context.get("issuer_did"),
"options": context.get("options", {}),
}
else:
# Fallback: assume it's already in the right format
return payload if isinstance(payload, dict) else {}


async def creddef_event_handler(profile: Profile, event: Event):
LOGGER.info("> creddef_event_handler")
LOGGER.debug(f"profile = {profile}")
LOGGER.debug(f"event = {event}")
LOGGER.debug(f"event.payload = {event.payload}")

srv = profile.inject(CredDefStorageService)
storage_record = await srv.add_item(profile, event.payload["context"])

# Normalize event payload to common format
normalized_data = _normalize_creddef_event_payload(event)
LOGGER.debug(f"normalized_data = {normalized_data}")

storage_record = await srv.add_item(profile, normalized_data)
LOGGER.debug(f"creddef_storage_record = {storage_record}")

LOGGER.info("< creddef_event_handler")
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from typing import Optional

from acapy_agent.messaging.models.base_record import BaseRecord, BaseRecordSchema
Expand All @@ -9,7 +10,7 @@
INDY_CRED_DEF_ID_VALIDATE,
INDY_CRED_DEF_ID_EXAMPLE,
)
from marshmallow import EXCLUDE, fields
from marshmallow import EXCLUDE, fields, ValidationError


class CredDefStorageRecord(BaseRecord):
Expand Down Expand Up @@ -56,6 +57,63 @@ def record_value(self) -> dict:
}


def validate_cred_def_id(value):
"""Validate credential definition ID as either Indy or AnonCreds format."""
if not value or not isinstance(value, str):
raise ValidationError("Credential definition ID must be a non-empty string")

if len(value.strip()) == 0:
raise ValidationError("Credential definition ID cannot be empty")

# Indy cred def ID format: DID:3:CL:(seq_no|schema_id):tag
# Pattern: ^[base58]{21,22}:3:CL:(([1-9][0-9]*)|([base58]{21,22}:2:.+:[0-9.]+)):(.+)?$
# Schema reference can be either a sequence number or full schema ID
indy_pattern = (
r"^[1-9A-HJ-NP-Za-km-z]{21,22}" # issuer DID
r":3" # cred def id marker
r":CL" # sig alg
r":(([1-9][0-9]*)|([1-9A-HJ-NP-Za-km-z]{21,22}:2:.+:[0-9.]+))" # schema txn/id
r":(.+)?$" # optional tag
)

# Check if it matches Indy format
if re.match(indy_pattern, value):
# Validate using the official Indy validator
try:
INDY_CRED_DEF_ID_VALIDATE(value)
except ValidationError:
# If Indy validator fails, still accept it (might be edge case)
pass

# Accept all non-empty strings as valid credential definition IDs
# (Indy format validated above, everything else is AnonCreds)
return


def validate_schema_id_for_creddef(value):
"""Validate schema ID as either Indy or AnonCreds format."""
if not value or not isinstance(value, str):
raise ValidationError("Schema ID must be a non-empty string")

if len(value.strip()) == 0:
raise ValidationError("Schema ID cannot be empty")

# Indy schema ID format: DID:2:name:version
indy_pattern = r"^[1-9A-HJ-NP-Za-km-z]{21,22}:2:.+:[0-9.]+$"

# Check if it matches Indy format
if re.match(indy_pattern, value):
# Validate using the official Indy validator
try:
INDY_SCHEMA_ID_VALIDATE(value)
except ValidationError:
# If Indy validator fails, still accept it (might be edge case)
pass

# Accept all non-empty strings as valid schema IDs
return


class CredDefStorageRecordSchema(BaseRecordSchema):
"""Traction CredDef Storage Record Schema."""

Expand All @@ -67,16 +125,16 @@ class Meta:

cred_def_id = fields.Str(
required=True,
validate=INDY_CRED_DEF_ID_VALIDATE,
validate=validate_cred_def_id,
metadata={
"description": "Cred Def identifier",
"description": "Cred Def identifier (Indy or AnonCreds format)",
"example": INDY_CRED_DEF_ID_EXAMPLE,
},
)
schema_id = fields.Str(
validate=INDY_SCHEMA_ID_VALIDATE,
validate=validate_schema_id_for_creddef,
metadata={
"description": "Schema identifier",
"description": "Schema identifier (Indy or AnonCreds format)",
"example": INDY_SCHEMA_ID_EXAMPLE,
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import logging

from aiohttp import web
from aiohttp_apispec import docs, response_schema, match_info_schema
from aiohttp_apispec import docs, response_schema, match_info_schema, request_schema
from acapy_agent.admin.request_context import AdminRequestContext

from acapy_agent.messaging.models.base import BaseModelError
Expand Down Expand Up @@ -58,6 +58,16 @@ class CredDefIdMatchInfoSchema(OpenAPISchema):
)


class CredDefStorageAddSchema(OpenAPISchema):
cred_def_id = fields.Str(
metadata={"description": "Credential Definition identifier"}, required=True
)
schema_id = fields.Str(
metadata={"description": "Schema identifier (Indy or AnonCreds format)"},
required=True,
)


class CredDefStorageOperationResponseSchema(OpenAPISchema):
"""Response schema for simple operations."""

Expand Down Expand Up @@ -88,6 +98,24 @@ async def creddef_storage_list(request: web.BaseRequest):
return web.json_response({"results": results})


@docs(
tags=[SWAGGER_CATEGORY],
)
@request_schema(CredDefStorageAddSchema())
@response_schema(CredDefStorageRecordSchema(), 200, description="")
@error_handler
@tenant_authentication
async def creddef_storage_add(request: web.BaseRequest):
context: AdminRequestContext = request["context"]
profile = context.profile
storage_srv = context.inject_or(CredDefStorageService)
body = await request.json()

record = await storage_srv.add_item(profile, body)

return web.json_response(record.serialize())


@docs(
tags=[SWAGGER_CATEGORY],
)
Expand Down Expand Up @@ -134,6 +162,7 @@ async def register(app: web.Application):
creddef_storage_list,
allow_head=False,
),
web.post("/credential-definition-storage", creddef_storage_add),
web.get(
"/credential-definition-storage/{cred_def_id}",
creddef_storage_get,
Expand Down
Loading
Loading