From ad946cf3f635b882d772a00b0202b957a1cb82cb Mon Sep 17 00:00:00 2001 From: Bassam Date: Mon, 30 Sep 2024 11:40:16 -0400 Subject: [PATCH 01/10] feat: VC support for Array of credential Status (#1383) Signed-off-by: Bassam Riman --- .../vc/jwt/VerifiableCredentialPayload.scala | 56 ++++++++++---- .../pollux/vc/jwt/JWTVerificationTest.scala | 77 ++++++++++++++++++- 2 files changed, 116 insertions(+), 17 deletions(-) diff --git a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/VerifiableCredentialPayload.scala b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/VerifiableCredentialPayload.scala index 3d24747bd1..5638ba028a 100644 --- a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/VerifiableCredentialPayload.scala +++ b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/VerifiableCredentialPayload.scala @@ -79,7 +79,7 @@ sealed trait CredentialPayload { def issuer: String | CredentialIssuer - def maybeCredentialStatus: Option[CredentialStatus] + def maybeCredentialStatus: Option[CredentialStatus | List[CredentialStatus]] def maybeRefreshService: Option[RefreshService] @@ -145,7 +145,7 @@ case class JwtVc( maybeValidFrom: Option[Instant], maybeValidUntil: Option[Instant], maybeIssuer: Option[String | CredentialIssuer], - maybeCredentialStatus: Option[CredentialStatus], + maybeCredentialStatus: Option[CredentialStatus | List[CredentialStatus]], maybeRefreshService: Option[RefreshService], maybeEvidence: Option[Json], maybeTermsOfUse: Option[Json] @@ -182,7 +182,7 @@ case class W3cCredentialPayload( maybeExpirationDate: Option[Instant], override val maybeCredentialSchema: Option[CredentialSchema | List[CredentialSchema]], override val credentialSubject: Json, - override val maybeCredentialStatus: Option[CredentialStatus], + override val maybeCredentialStatus: Option[CredentialStatus | List[CredentialStatus]], override val maybeRefreshService: Option[RefreshService], override val maybeEvidence: Option[Json], override val maybeTermsOfUse: Option[Json], @@ -239,6 +239,11 @@ object CredentialPayload { ("statusListCredential", credentialStatus.statusListCredential.asJson) ) + implicit val credentialStatusOrListEncoder: Encoder[CredentialStatus | List[CredentialStatus]] = Encoder.instance { + case status: CredentialStatus => Encoder[CredentialStatus].apply(status) + case statusList: List[CredentialStatus] => Encoder[List[CredentialStatus]].apply(statusList) + } + implicit val stringOrCredentialIssuerEncoder: Encoder[String | CredentialIssuer] = Encoder.instance { case string: String => Encoder[String].apply(string) case credentialIssuer: CredentialIssuer => Encoder[CredentialIssuer].apply(credentialIssuer) @@ -383,6 +388,11 @@ object CredentialPayload { .map(schema => schema: CredentialSchema | List[CredentialSchema]) .or(Decoder[List[CredentialSchema]].map(schema => schema: CredentialSchema | List[CredentialSchema])) + implicit val credentialStatusOrListDecoder: Decoder[CredentialStatus | List[CredentialStatus]] = + Decoder[CredentialStatus] + .map(status => status: CredentialStatus | List[CredentialStatus]) + .or(Decoder[List[CredentialStatus]].map(status => status: CredentialStatus | List[CredentialStatus])) + implicit val w3cCredentialPayloadDecoder: Decoder[W3cCredentialPayload] = (c: HCursor) => for { @@ -404,7 +414,7 @@ object CredentialPayload { .downField("credentialSchema") .as[Option[CredentialSchema | List[CredentialSchema]]] credentialSubject <- c.downField("credentialSubject").as[Json] - maybeCredentialStatus <- c.downField("credentialStatus").as[Option[CredentialStatus]] + maybeCredentialStatus <- c.downField("credentialStatus").as[Option[CredentialStatus | List[CredentialStatus]]] maybeRefreshService <- c.downField("refreshService").as[Option[RefreshService]] maybeEvidence <- c.downField("evidence").as[Option[Json]] maybeTermsOfUse <- c.downField("termsOfUse").as[Option[Json]] @@ -443,7 +453,7 @@ object CredentialPayload { .downField("credentialSchema") .as[Option[CredentialSchema | List[CredentialSchema]]] credentialSubject <- c.downField("credentialSubject").as[Json] - maybeCredentialStatus <- c.downField("credentialStatus").as[Option[CredentialStatus]] + maybeCredentialStatus <- c.downField("credentialStatus").as[Option[CredentialStatus | List[CredentialStatus]]] maybeRefreshService <- c.downField("refreshService").as[Option[RefreshService]] maybeEvidence <- c.downField("evidence").as[Option[Json]] maybeTermsOfUse <- c.downField("termsOfUse").as[Option[Json]] @@ -837,7 +847,7 @@ object JwtCredential { } yield Validation.validateWith(signatureValidation, dateVerification, revocationVerification)((a, _, _) => a) } - private def verifyRevocationStatusJwt(jwt: JWT)(uriResolver: UriResolver): IO[String, Validation[String, Unit]] = { + def verifyRevocationStatusJwt(jwt: JWT)(uriResolver: UriResolver): IO[String, Validation[String, Unit]] = { val decodeJWT = ZIO .fromTry(JwtCirce.decodeRaw(jwt.value, options = JwtOptions(false, false, false))) @@ -847,12 +857,19 @@ object JwtCredential { decodedJWT <- decodeJWT jwtCredentialPayload <- ZIO.fromEither(decode[JwtCredentialPayload](decodedJWT)).mapError(_.getMessage) credentialStatus = jwtCredentialPayload.vc.maybeCredentialStatus - result = credentialStatus.fold(ZIO.succeed(Validation.unit))(status => - CredentialVerification.verifyCredentialStatus(status)(uriResolver) + .map { + { + case status: CredentialStatus => List(status) + case statusList: List[CredentialStatus] => statusList + } + } + .getOrElse(List.empty) + results <- ZIO.collectAll( + credentialStatus.map(status => CredentialVerification.verifyCredentialStatus(status)(uriResolver)) ) + result = Validation.validateAll(results).flatMap(_ => Validation.unit) } yield result - - res.flatten + res } } @@ -927,11 +944,20 @@ object W3CCredential { private def verifyRevocationStatusW3c( w3cPayload: W3cVerifiableCredentialPayload, )(uriResolver: UriResolver): IO[String, Validation[String, Unit]] = { - // If credential does not have credential status list, it does not support revocation - // and we assume revocation status is valid. - w3cPayload.payload.maybeCredentialStatus.fold(ZIO.succeed(Validation.unit))(status => - CredentialVerification.verifyCredentialStatus(status)(uriResolver) - ) + val credentialStatus = w3cPayload.payload.maybeCredentialStatus + .map { + { + case status: CredentialStatus => List(status) + case statusList: List[CredentialStatus] => statusList + } + } + .getOrElse(List.empty) + for { + results <- ZIO.collectAll( + credentialStatus.map(status => CredentialVerification.verifyCredentialStatus(status)(uriResolver)) + ) + result = Validation.validateAll(results).flatMap(_ => Validation.unit) + } yield result } def verify(w3cPayload: W3cVerifiableCredentialPayload, options: CredentialVerification.CredentialVerificationOptions)( diff --git a/pollux/vc-jwt/src/test/scala/org/hyperledger/identus/pollux/vc/jwt/JWTVerificationTest.scala b/pollux/vc-jwt/src/test/scala/org/hyperledger/identus/pollux/vc/jwt/JWTVerificationTest.scala index e09f9e6e96..8222a4fe64 100644 --- a/pollux/vc-jwt/src/test/scala/org/hyperledger/identus/pollux/vc/jwt/JWTVerificationTest.scala +++ b/pollux/vc-jwt/src/test/scala/org/hyperledger/identus/pollux/vc/jwt/JWTVerificationTest.scala @@ -7,6 +7,7 @@ import io.circe.* import io.circe.syntax.* import org.hyperledger.identus.castor.core.model.did.{DID, VerificationRelationship} import org.hyperledger.identus.pollux.vc.jwt.CredentialPayload.Implicits.* +import org.hyperledger.identus.pollux.vc.jwt.StatusPurpose.Revocation import org.hyperledger.identus.shared.http.* import zio.* import zio.prelude.Validation @@ -62,7 +63,11 @@ object JWTVerificationTest extends ZIOSpecDefault { |} |""".stripMargin - private def createJwtCredential(issuer: IssuerWithKey, issuerAsObject: Boolean = false): JWT = { + private def createJwtCredential( + issuer: IssuerWithKey, + issuerAsObject: Boolean = false, + credentialStatus: Option[CredentialStatus | List[CredentialStatus]] = None + ): JWT = { val validFrom = Instant.parse("2010-01-05T00:00:00Z") // ISSUANCE DATE val jwtCredentialNbf = Instant.parse("2010-01-01T00:00:00Z") // ISSUANCE DATE val validUntil = Instant.parse("2010-01-09T00:00:00Z") // EXPIRATION DATE @@ -75,7 +80,7 @@ object JWTVerificationTest extends ZIOSpecDefault { `type` = Set("VerifiableCredential", "UniversityDegreeCredential"), maybeCredentialSchema = None, credentialSubject = Json.obj("id" -> Json.fromString("1")), - maybeCredentialStatus = None, + maybeCredentialStatus = credentialStatus, maybeRefreshService = None, maybeEvidence = None, maybeTermsOfUse = None, @@ -190,6 +195,51 @@ object JWTVerificationTest extends ZIOSpecDefault { ) ) }, + test("fail verification if proof is valid but credential is revoked at the give status list index given list") { + val revokedStatus: List[CredentialStatus] = List( + org.hyperledger.identus.pollux.vc.jwt.CredentialStatus( + id = "http://localhost:8085/credential-status/664382dc-9e6d-4d0c-99d1-85e2c74eb5e9#1", + statusPurpose = StatusPurpose.Revocation, + `type` = "StatusList2021Entry", + statusListCredential = "http://localhost:8085/credential-status/664382dc-9e6d-4d0c-99d1-85e2c74eb5e9", + statusListIndex = 1 + ), + org.hyperledger.identus.pollux.vc.jwt.CredentialStatus( + id = "http://localhost:8085/credential-status/664382dc-9e6d-4d0c-99d1-85e2c74eb5e9#2", + statusPurpose = StatusPurpose.Suspension, + `type` = "StatusList2021Entry", + statusListCredential = "http://localhost:8085/credential-status/664382dc-9e6d-4d0c-99d1-85e2c74eb5e9", + statusListIndex = 1 + ) + ) + + val urlResolver = new UriResolver { + override def resolve(uri: String): IO[GenericUriResolverError, String] = { + ZIO.succeed(statusListCredentialString) + } + } + + val genericUriResolver = GenericUriResolver( + Map( + "data" -> DataUrlResolver(), + "http" -> urlResolver, + "https" -> urlResolver + ) + ) + val issuer = createUser("did:prism:issuer") + val jwtCredential = createJwtCredential(issuer, credentialStatus = Some(revokedStatus)) + + for { + validation <- JwtCredential.verifyRevocationStatusJwt(jwtCredential)(genericUriResolver) + } yield assertTrue( + validation.fold( + chunk => + chunk.length == 2 && chunk.head.contentEquals("Credential is revoked") && chunk.tail.head + .contentEquals("Credential is revoked"), + _ => false + ) + ) + }, test("validate dates happy path") { val issuer = createUser("did:prism:issuer") val jwtCredential = createJwtCredential(issuer) @@ -223,6 +273,29 @@ object JWTVerificationTest extends ZIOSpecDefault { jwtWithObjectIssuerIssuer.equals(jwtIssuer) ) }, + test("validate credential status list") { + val issuer = createUser("did:prism:issuer") + val status = CredentialStatus(id = "id", `type` = "type", statusPurpose = Revocation, 1, "1") + val encodedJwtWithStatusList = createJwtCredential( + issuer, + false, + Some(List(status)) + ) + val econdedJwtWithStatusObject = createJwtCredential(issuer, true, Some(status)) + for { + decodeJwtWithStatusList <- JwtCredential + .decodeJwt(encodedJwtWithStatusList) + decodeJwtWithStatusObject <- JwtCredential + .decodeJwt(econdedJwtWithStatusObject) + statusFromList = decodeJwtWithStatusList.vc.maybeCredentialStatus.map { + case list: List[CredentialStatus] => list.head + case _: CredentialStatus => throw new IllegalStateException("List expected") + }.get + statusFromObjet = decodeJwtWithStatusObject.vc.maybeCredentialStatus.get + } yield assertTrue( + statusFromList.equals(statusFromObjet) + ) + }, test("validate dates should fail given after valid until") { val issuer = createUser("did:prism:issuer") val jwtCredential = createJwtCredential(issuer) From 7b4a9c1f8e1cb0b16d2da9b57e80d41cb2b5d478 Mon Sep 17 00:00:00 2001 From: Allain Magyar Date: Wed, 2 Oct 2024 16:00:34 -0300 Subject: [PATCH 02/10] test: add oid4vci negative scenario tests (#1380) Signed-off-by: Allain Magyar Signed-off-by: Hyperledger Bot Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Hyperledger Bot --- tests/integration-tests/README.md | 4 - .../src/test/kotlin/config/services/Agent.kt | 1 - .../test/kotlin/config/services/Keycloak.kt | 1 - .../kotlin/config/services/ServiceBase.kt | 5 +- .../src/test/kotlin/config/services/Vault.kt | 1 - .../config/services/VerifiableDataRegistry.kt | 1 - .../steps/oid4vci/IssueCredentialSteps.kt | 18 ++- .../oid4vci/ManageCredentialConfigSteps.kt | 125 ++++++++++++++--- .../kotlin/steps/oid4vci/ManageIssuerSteps.kt | 129 +++++++++++++++--- .../features/oid4vci/issue_jwt.feature | 6 +- .../oid4vci/manage_credential_config.feature | 31 ++++- .../features/oid4vci/manage_issuer.feature | 27 +++- 12 files changed, 280 insertions(+), 69 deletions(-) diff --git a/tests/integration-tests/README.md b/tests/integration-tests/README.md index 2fef57bf71..4b0e925bcd 100644 --- a/tests/integration-tests/README.md +++ b/tests/integration-tests/README.md @@ -116,10 +116,6 @@ The configuration files are divided into the following sections: * `agents`: contains the configuration for the agents (ICA) that will be started. By default, all agents will be destroyed after the test run is finished. * `roles`: contains the configuration for the roles (Issuer, Holder, Verifier, Admin). A role can be assigned to one or more agents that we set in `agents` section or already running locally or in the cloud. -> You could keep services and agents running for debugging purposes -> by specifying `keep_running = true` for the service or agent -> in the configuration file and setting `TESTCONTAINERS_RYUK_DISABLED` variable to `true`. - Please, check [test/resources/configs/basic.conf](./src/test/resources/configs/basic.conf) for a quick example of a basic configuration. You could explore the `configs` directory for more complex examples. diff --git a/tests/integration-tests/src/test/kotlin/config/services/Agent.kt b/tests/integration-tests/src/test/kotlin/config/services/Agent.kt index f4d7cc0962..a4e97a603d 100644 --- a/tests/integration-tests/src/test/kotlin/config/services/Agent.kt +++ b/tests/integration-tests/src/test/kotlin/config/services/Agent.kt @@ -16,7 +16,6 @@ data class Agent( @ConfigAlias("prism_node") val prismNode: VerifiableDataRegistry?, val keycloak: Keycloak?, val vault: Vault?, - @ConfigAlias("keep_running") override val keepRunning: Boolean = false, ) : ServiceBase() { override val logServices = listOf("identus-cloud-agent") diff --git a/tests/integration-tests/src/test/kotlin/config/services/Keycloak.kt b/tests/integration-tests/src/test/kotlin/config/services/Keycloak.kt index d73a8814b5..86027fe3ad 100644 --- a/tests/integration-tests/src/test/kotlin/config/services/Keycloak.kt +++ b/tests/integration-tests/src/test/kotlin/config/services/Keycloak.kt @@ -17,7 +17,6 @@ data class Keycloak( val realm: String = "atala-demo", @ConfigAlias("client_id") val clientId: String = "cloud-agent", @ConfigAlias("client_secret") val clientSecret: String = "cloud-agent-secret", - @ConfigAlias("keep_running") override val keepRunning: Boolean = false, @ConfigAlias("compose_file") val keycloakComposeFile: String = "src/test/resources/containers/keycloak.yml", @ConfigAlias("logger_name") val loggerName: String = "keycloak", @ConfigAlias("extra_envs") val extraEnvs: Map = emptyMap(), diff --git a/tests/integration-tests/src/test/kotlin/config/services/ServiceBase.kt b/tests/integration-tests/src/test/kotlin/config/services/ServiceBase.kt index 8c598b61d6..9c2483084a 100644 --- a/tests/integration-tests/src/test/kotlin/config/services/ServiceBase.kt +++ b/tests/integration-tests/src/test/kotlin/config/services/ServiceBase.kt @@ -15,7 +15,6 @@ abstract class ServiceBase : Startable { } abstract val container: ComposeContainer - abstract val keepRunning: Boolean open val logServices: List = emptyList() private val logWriters: MutableList = mutableListOf() @@ -41,8 +40,6 @@ abstract class ServiceBase : Startable { logWriters.forEach { it.close() } - if (!keepRunning) { - container.stop() - } + container.stop() } } diff --git a/tests/integration-tests/src/test/kotlin/config/services/Vault.kt b/tests/integration-tests/src/test/kotlin/config/services/Vault.kt index 85f1a02b27..a14a44620b 100644 --- a/tests/integration-tests/src/test/kotlin/config/services/Vault.kt +++ b/tests/integration-tests/src/test/kotlin/config/services/Vault.kt @@ -14,7 +14,6 @@ import java.io.File data class Vault( @ConfigAlias("http_port") val httpPort: Int, @ConfigAlias("vault_auth_type") val authType: VaultAuthType = VaultAuthType.APP_ROLE, - @ConfigAlias("keep_running") override val keepRunning: Boolean = false, ) : ServiceBase() { private val logger = Logger.get() override val logServices: List = listOf("vault") diff --git a/tests/integration-tests/src/test/kotlin/config/services/VerifiableDataRegistry.kt b/tests/integration-tests/src/test/kotlin/config/services/VerifiableDataRegistry.kt index 2997f567cc..f4fbcdba66 100644 --- a/tests/integration-tests/src/test/kotlin/config/services/VerifiableDataRegistry.kt +++ b/tests/integration-tests/src/test/kotlin/config/services/VerifiableDataRegistry.kt @@ -8,7 +8,6 @@ import java.io.File data class VerifiableDataRegistry( @ConfigAlias("http_port") val httpPort: Int, val version: String, - @ConfigAlias("keep_running") override val keepRunning: Boolean = false, ) : ServiceBase() { override val logServices: List = listOf("prism-node") private val vdrComposeFile = "src/test/resources/containers/vdr.yml" diff --git a/tests/integration-tests/src/test/kotlin/steps/oid4vci/IssueCredentialSteps.kt b/tests/integration-tests/src/test/kotlin/steps/oid4vci/IssueCredentialSteps.kt index 3cd1873033..6a2e906361 100644 --- a/tests/integration-tests/src/test/kotlin/steps/oid4vci/IssueCredentialSteps.kt +++ b/tests/integration-tests/src/test/kotlin/steps/oid4vci/IssueCredentialSteps.kt @@ -5,6 +5,7 @@ import com.nimbusds.jose.JWSAlgorithm import com.nimbusds.jose.jwk.JWK import eu.europa.ec.eudi.openid4vci.* import interactions.Post +import interactions.body import io.cucumber.java.en.Then import io.cucumber.java.en.When import io.iohk.atala.automation.extensions.get @@ -40,16 +41,13 @@ class IssueCredentialSteps { issuer.recall("longFormDid") } issuer.attemptsTo( - Post.to("/oid4vci/issuers/${credentialIssuer.id}/credential-offers") - .with { - it.body( - CredentialOfferRequest( - credentialConfigurationId = configurationId, - issuingDID = did, - claims = claims, - ), - ) - }, + Post.to("/oid4vci/issuers/${credentialIssuer.id}/credential-offers").body( + CredentialOfferRequest( + credentialConfigurationId = configurationId, + issuingDID = did, + claims = claims, + ), + ), Ensure.thatTheLastResponse().statusCode().isEqualTo(HttpStatus.SC_CREATED), ) val offerUri = SerenityRest.lastResponse().get().credentialOffer diff --git a/tests/integration-tests/src/test/kotlin/steps/oid4vci/ManageCredentialConfigSteps.kt b/tests/integration-tests/src/test/kotlin/steps/oid4vci/ManageCredentialConfigSteps.kt index 470babb703..8a53c311f9 100644 --- a/tests/integration-tests/src/test/kotlin/steps/oid4vci/ManageCredentialConfigSteps.kt +++ b/tests/integration-tests/src/test/kotlin/steps/oid4vci/ManageCredentialConfigSteps.kt @@ -1,20 +1,37 @@ package steps.oid4vci +import com.google.gson.JsonObject import common.CredentialSchema -import interactions.* -import io.cucumber.java.en.* +import interactions.Delete +import interactions.Get +import interactions.Post +import interactions.body +import io.cucumber.java.en.Given +import io.cucumber.java.en.Then +import io.cucumber.java.en.When import io.iohk.atala.automation.extensions.get import io.iohk.atala.automation.serenity.ensure.Ensure import net.serenitybdd.rest.SerenityRest import net.serenitybdd.screenplay.Actor import org.apache.http.HttpStatus -import org.hyperledger.identus.client.models.* +import org.apache.http.HttpStatus.SC_OK +import org.hyperledger.identus.client.models.CreateCredentialConfigurationRequest +import org.hyperledger.identus.client.models.CredentialFormat +import org.hyperledger.identus.client.models.CredentialIssuer +import org.hyperledger.identus.client.models.IssuerMetadata +import java.util.UUID class ManageCredentialConfigSteps { @Given("{actor} has {string} credential configuration created from {}") fun issuerHasExistingCredentialConfig(issuer: Actor, configurationId: String, schema: CredentialSchema) { ManageIssuerSteps().issuerHasExistingCredentialIssuer(issuer) - issuerCreateCredentialConfiguration(issuer, schema, configurationId) + val credentialIssuer = issuer.recall("oid4vciCredentialIssuer") + issuer.attemptsTo( + Get("/oid4vci/issuers/${credentialIssuer.id}/credential-configurations/$configurationId"), + ) + if (SerenityRest.lastResponse().statusCode != SC_OK) { + issuerCreateCredentialConfiguration(issuer, schema, configurationId) + } } @When("{actor} uses {} to create a credential configuration {string}") @@ -22,17 +39,15 @@ class ManageCredentialConfigSteps { val credentialIssuer = issuer.recall("oid4vciCredentialIssuer") val schemaGuid = issuer.recall(schema.name) val baseUrl = issuer.recall("baseUrl") + issuer.attemptsTo( - Post.to("/oid4vci/issuers/${credentialIssuer.id}/credential-configurations") - .with { - it.body( - CreateCredentialConfigurationRequest( - configurationId = configurationId, - format = CredentialFormat.JWT_VC_JSON, - schemaId = "$baseUrl/schema-registry/schemas/$schemaGuid/schema", - ), - ) - }, + Post.to("/oid4vci/issuers/${credentialIssuer.id}/credential-configurations").body( + CreateCredentialConfigurationRequest( + configurationId = configurationId, + format = CredentialFormat.JWT_VC_JSON, + schemaId = "$baseUrl/schema-registry/schemas/$schemaGuid/schema", + ), + ), Ensure.thatTheLastResponse().statusCode().isEqualTo(HttpStatus.SC_CREATED), ) } @@ -42,7 +57,75 @@ class ManageCredentialConfigSteps { val credentialIssuer = issuer.recall("oid4vciCredentialIssuer") issuer.attemptsTo( Delete("/oid4vci/issuers/${credentialIssuer.id}/credential-configurations/$configurationId"), - Ensure.thatTheLastResponse().statusCode().isEqualTo(HttpStatus.SC_OK), + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), + ) + } + + @When("{actor} deletes a non existent {} credential configuration") + fun issuerDeletesANonExistentCredentialConfiguration(issuer: Actor, configurationId: String) { + val credentialIssuer = issuer.recall("oid4vciCredentialIssuer") + issuer.attemptsTo( + Delete("/oid4vci/issuers/${credentialIssuer.id}/credential-configurations/$configurationId"), + ) + } + + @When("{actor} creates a new credential configuration request") + fun issuerCreatesANewConfigurationRequest(issuer: Actor) { + val credentialConfiguration = JsonObject() + issuer.remember("credentialConfiguration", credentialConfiguration) + } + + @When("{actor} uses {} issuer id for credential configuration") + fun issuerUsesIssuerId(issuer: Actor, issuerId: String) { + if (issuerId == "existing") { + val credentialIssuer = issuer.recall("oid4vciCredentialIssuer") + issuer.remember("credentialConfigurationId", credentialIssuer.id) + } else if (issuerId == "wrong") { + issuer.remember("credentialConfigurationId", UUID.randomUUID().toString()) + } + } + + @When("{actor} adds '{}' configuration id for credential configuration request") + fun issuerAddsConfigurationIdToCredentialConfigurationRequest(issuer: Actor, configurationId: String) { + val credentialIssuer = issuer.recall("credentialConfiguration") + val configurationIdProperty = if (configurationId == "null") { + null + } else { + configurationId + } + credentialIssuer.addProperty("configurationId", configurationIdProperty) + } + + @When("{actor} adds '{}' format for credential configuration request") + fun issuerAddsFormatToCredentialConfigurationRequest(issuer: Actor, format: String) { + val credentialIssuer = issuer.recall("credentialConfiguration") + val formatProperty = if (format == "null") { + null + } else { + format + } + credentialIssuer.addProperty("format", formatProperty) + } + + @When("{actor} adds '{}' schemaId for credential configuration request") + fun issuerAddsSchemaIdToCredentialConfigurationRequest(issuer: Actor, schema: String) { + val credentialIssuer = issuer.recall("credentialConfiguration") + val schemaIdProperty = if (schema == "null") { + null + } else { + val baseUrl = issuer.recall("baseUrl") + val schemaGuid = issuer.recall(schema) + "$baseUrl/schema-registry/schemas/$schemaGuid/schema" + } + credentialIssuer.addProperty("schemaId", schemaIdProperty) + } + + @When("{actor} sends the create a credential configuration request") + fun issuerSendsTheCredentialConfigurationRequest(issuer: Actor) { + val credentialConfiguration = issuer.recall("credentialConfiguration") + val credentialIssuerId = issuer.recall("credentialConfigurationId").toString() + issuer.attemptsTo( + Post.to("/oid4vci/issuers/$credentialIssuerId/credential-configurations").body(credentialConfiguration), ) } @@ -51,7 +134,7 @@ class ManageCredentialConfigSteps { val credentialIssuer = issuer.recall("oid4vciCredentialIssuer") issuer.attemptsTo( Get("/oid4vci/issuers/${credentialIssuer.id}/.well-known/openid-credential-issuer"), - Ensure.thatTheLastResponse().statusCode().isEqualTo(HttpStatus.SC_OK), + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), ) val metadata = SerenityRest.lastResponse().get() val credConfig = metadata.credentialConfigurationsSupported[configurationId]!! @@ -65,11 +148,19 @@ class ManageCredentialConfigSteps { val credentialIssuer = issuer.recall("oid4vciCredentialIssuer") issuer.attemptsTo( Get("/oid4vci/issuers/${credentialIssuer.id}/.well-known/openid-credential-issuer"), - Ensure.thatTheLastResponse().statusCode().isEqualTo(HttpStatus.SC_OK), + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), ) val metadata = SerenityRest.lastResponse().get() issuer.attemptsTo( Ensure.that(metadata.credentialConfigurationsSupported.keys).doesNotContain(configurationId), ) } + + @Then("{actor} should see that create credential configuration has failed with '{}' status code and '{}' detail") + fun issuerShouldSeeCredentialConfigurationRequestHasFailed(issuer: Actor, statusCode: Int, errorDetail: String) { + issuer.attemptsTo( + Ensure.thatTheLastResponse().statusCode().isEqualTo(statusCode), + Ensure.that(SerenityRest.lastResponse().body.asString()).contains(errorDetail), + ) + } } diff --git a/tests/integration-tests/src/test/kotlin/steps/oid4vci/ManageIssuerSteps.kt b/tests/integration-tests/src/test/kotlin/steps/oid4vci/ManageIssuerSteps.kt index 7db69f0b41..e78408078f 100644 --- a/tests/integration-tests/src/test/kotlin/steps/oid4vci/ManageIssuerSteps.kt +++ b/tests/integration-tests/src/test/kotlin/steps/oid4vci/ManageIssuerSteps.kt @@ -1,7 +1,15 @@ package steps.oid4vci -import interactions.* -import io.cucumber.java.en.* +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import interactions.Delete +import interactions.Get +import interactions.Patch +import interactions.Post +import interactions.body +import io.cucumber.java.en.Given +import io.cucumber.java.en.Then +import io.cucumber.java.en.When import io.iohk.atala.automation.extensions.get import io.iohk.atala.automation.serenity.ensure.Ensure import net.serenitybdd.rest.SerenityRest @@ -9,16 +17,26 @@ import net.serenitybdd.screenplay.Actor import org.apache.http.HttpStatus import org.apache.http.HttpStatus.SC_CREATED import org.apache.http.HttpStatus.SC_OK -import org.hyperledger.identus.client.models.* +import org.hyperledger.identus.client.models.AuthorizationServer +import org.hyperledger.identus.client.models.CreateCredentialIssuerRequest +import org.hyperledger.identus.client.models.CredentialIssuer +import org.hyperledger.identus.client.models.CredentialIssuerPage +import org.hyperledger.identus.client.models.IssuerMetadata +import org.hyperledger.identus.client.models.PatchAuthorizationServer +import org.hyperledger.identus.client.models.PatchCredentialIssuerRequest class ManageIssuerSteps { - private val UPDATE_AUTH_SERVER_URL = "http://example.com" - private val UPDATE_AUTH_SERVER_CLIENT_ID = "foo" - private val UPDATE_AUTH_SERVER_CLIENT_SECRET = "bar" + companion object { + private const val UPDATE_AUTH_SERVER_URL = "http://example.com" + private const val UPDATE_AUTH_SERVER_CLIENT_ID = "foo" + private const val UPDATE_AUTH_SERVER_CLIENT_SECRET = "bar" + } @Given("{actor} has an existing oid4vci issuer") fun issuerHasExistingCredentialIssuer(issuer: Actor) { - issuerCreateCredentialIssuer(issuer) + if (!issuer.recallAll().containsKey("oid4vciCredentialIssuer")) { + issuerCreateCredentialIssuer(issuer) + } } @When("{actor} creates an oid4vci issuer") @@ -69,19 +87,29 @@ class ManageIssuerSteps { fun issuerUpdateCredentialIssuer(issuer: Actor) { val credentialIssuer = issuer.recall("oid4vciCredentialIssuer") issuer.attemptsTo( - Patch.to("/oid4vci/issuers/${credentialIssuer.id}") - .with { - it.body( - PatchCredentialIssuerRequest( - authorizationServer = PatchAuthorizationServer( - url = UPDATE_AUTH_SERVER_URL, - clientId = UPDATE_AUTH_SERVER_CLIENT_ID, - clientSecret = UPDATE_AUTH_SERVER_CLIENT_SECRET, - ), - ), - ) - }, - Ensure.thatTheLastResponse().statusCode().isEqualTo(HttpStatus.SC_OK), + Patch.to("/oid4vci/issuers/${credentialIssuer.id}").body( + PatchCredentialIssuerRequest( + authorizationServer = PatchAuthorizationServer( + url = UPDATE_AUTH_SERVER_URL, + clientId = UPDATE_AUTH_SERVER_CLIENT_ID, + clientSecret = UPDATE_AUTH_SERVER_CLIENT_SECRET, + ), + ), + ), + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), + ) + } + + @When("{actor} tries to update the oid4vci issuer '{}' property using '{}' value") + fun issuerTriesToUpdateTheOID4VCIIssuer(issuer: Actor, property: String, value: String) { + val credentialIssuer = issuer.recall("oid4vciCredentialIssuer") + val body = JsonObject() + val propertyValue = if (value == "null") { null } else { value } + body.addProperty(property, propertyValue) + + val gson = GsonBuilder().serializeNulls().create() + issuer.attemptsTo( + Patch.to("/oid4vci/issuers/${credentialIssuer.id}").body(gson.toJson(body)), ) } @@ -94,6 +122,60 @@ class ManageIssuerSteps { ) } + @When("{actor} tries to create oid4vci issuer with '{}', '{}', '{}' and '{}'") + fun issuerTriesToCreateOIDCIssuer( + issuer: Actor, + id: String, + url: String, + clientId: String, + clientSecret: String, + ) { + val idProperty = if (id == "null") { + null + } else { + id + } + val urlProperty = if (url == "null") { + null + } else { + url + } + val clientIdProperty = if (clientId == "null") { + null + } else { + clientId + } + val clientSecretProperty = if (clientSecret == "null") { + null + } else { + clientSecret + } + + val body = JsonObject() + val authorizationServer = JsonObject() + + body.addProperty("id", idProperty) + body.add("authorizationServer", authorizationServer) + + authorizationServer.addProperty("url", urlProperty) + authorizationServer.addProperty("clientId", clientIdProperty) + authorizationServer.addProperty("clientSecret", clientSecretProperty) + + val gson = GsonBuilder().serializeNulls().create() + issuer.attemptsTo( + Post.to("/oid4vci/issuers").body(gson.toJson(body)), + ) + } + + @Then("{actor} should see the oid4vci '{}' http status response with '{}' detail") + fun issuerShouldSeeTheOIDC4VCIError(issuer: Actor, httpStatus: Int, errorDetail: String) { + SerenityRest.lastResponse().body.prettyPrint() + issuer.attemptsTo( + Ensure.that(SerenityRest.lastResponse().statusCode).isEqualTo(httpStatus), + Ensure.that(SerenityRest.lastResponse().body.asString()).contains(errorDetail), + ) + } + @Then("{actor} sees the oid4vci issuer updated with new values") fun issuerSeesUpdatedCredentialIssuer(issuer: Actor) { val credentialIssuer = issuer.recall("oid4vciCredentialIssuer") @@ -143,4 +225,11 @@ class ManageIssuerSteps { Ensure.thatTheLastResponse().statusCode().isEqualTo(HttpStatus.SC_NOT_FOUND), ) } + + @Then("{actor} should see the update oid4vci issuer returned '{}' http status") + fun issuerShouldSeeTheUpdateOID4VCIIssuerReturnedHttpStatus(issuer: Actor, statusCode: Int) { + issuer.attemptsTo( + Ensure.thatTheLastResponse().statusCode().isEqualTo(statusCode), + ) + } } diff --git a/tests/integration-tests/src/test/resources/features/oid4vci/issue_jwt.feature b/tests/integration-tests/src/test/resources/features/oid4vci/issue_jwt.feature index 2f30658ad8..53d010c2c5 100644 --- a/tests/integration-tests/src/test/resources/features/oid4vci/issue_jwt.feature +++ b/tests/integration-tests/src/test/resources/features/oid4vci/issue_jwt.feature @@ -1,20 +1,20 @@ @oid4vci Feature: Issue JWT Credentials using OID4VCI authorization code flow -Background: + Background: Given Issuer has a published DID for JWT And Issuer has published STUDENT_SCHEMA schema And Issuer has an existing oid4vci issuer And Issuer has "StudentProfile" credential configuration created from STUDENT_SCHEMA -Scenario: Issuing credential with published PRISM DID + Scenario: Issuing credential with published PRISM DID When Issuer creates an offer using "StudentProfile" configuration with "short" form DID And Holder receives oid4vci offer from Issuer And Holder resolves oid4vci issuer metadata and login via front-end channel And Holder presents the access token with JWT proof on CredentialEndpoint Then Holder sees credential issued successfully from CredentialEndpoint -Scenario: Issuing credential with unpublished PRISM DID + Scenario: Issuing credential with unpublished PRISM DID When Issuer creates an offer using "StudentProfile" configuration with "long" form DID And Holder receives oid4vci offer from Issuer And Holder resolves oid4vci issuer metadata and login via front-end channel diff --git a/tests/integration-tests/src/test/resources/features/oid4vci/manage_credential_config.feature b/tests/integration-tests/src/test/resources/features/oid4vci/manage_credential_config.feature index 3253069abe..cacdeb6cdb 100644 --- a/tests/integration-tests/src/test/resources/features/oid4vci/manage_credential_config.feature +++ b/tests/integration-tests/src/test/resources/features/oid4vci/manage_credential_config.feature @@ -1,16 +1,39 @@ @oid4vci Feature: Manage OID4VCI credential configuration -Background: + Background: Given Issuer has a published DID for JWT And Issuer has published STUDENT_SCHEMA schema And Issuer has an existing oid4vci issuer -Scenario: Successfully create credential configuration - When Issuer uses STUDENT_SCHEMA to create a credential configuration "StudentProfile" + Scenario: Successfully create credential configuration + Given Issuer has "StudentProfile" credential configuration created from STUDENT_SCHEMA Then Issuer sees the "StudentProfile" configuration on IssuerMetadata endpoint -Scenario: Successfully delete credential configuration + Scenario: Successfully delete credential configuration Given Issuer has "StudentProfile" credential configuration created from STUDENT_SCHEMA When Issuer deletes "StudentProfile" credential configuration Then Issuer cannot see the "StudentProfile" configuration on IssuerMetadata endpoint + + Scenario Outline: Create configuration with expect code + When Issuer creates a new credential configuration request + And Issuer uses issuer id for credential configuration + And Issuer adds '' configuration id for credential configuration request + And Issuer adds '' format for credential configuration request + And Issuer adds '' schemaId for credential configuration request + And Issuer sends the create a credential configuration request + Then Issuer should see that create credential configuration has failed with '' status code and '' detail + Examples: + | issuerId | configurationId | format | schemaId | httpStatus | errorDetail | description | + | wrong | StudentProfile | jwt_vc_json | STUDENT_SCHEMA | 404 | There is no credential issue | wrong issuer id | + | existing | null | jwt_vc_json | STUDENT_SCHEMA | 400 | configurationId | null configuration id | + | existing | StudentProfile | null | STUDENT_SCHEMA | 400 | format | null format | + | existing | StudentProfile | wrong-format | STUDENT_SCHEMA | 400 | format | wrong format | + | existing | StudentProfile | jwt_vc_json | null | 400 | schemaId | null schema | + | existing | StudentProfile | jwt_vc_json | malformed-schema | 400 | | malformed schema | + | existing | StudentProfile | jwt_vc_json | STUDENT_SCHEMA | 201 | | right values | + | existing | StudentProfile | jwt_vc_json | STUDENT_SCHEMA | 409 | Duplicated credential | duplicated configuration id | + + Scenario: Delete non existent credential configuration + When Issuer deletes a non existent "NonExistentProfile" credential configuration + Then Issuer should see that create credential configuration has failed with '404' status code and 'There is no credential configuration' detail diff --git a/tests/integration-tests/src/test/resources/features/oid4vci/manage_issuer.feature b/tests/integration-tests/src/test/resources/features/oid4vci/manage_issuer.feature index d2b6bd4aa6..6259934824 100644 --- a/tests/integration-tests/src/test/resources/features/oid4vci/manage_issuer.feature +++ b/tests/integration-tests/src/test/resources/features/oid4vci/manage_issuer.feature @@ -1,19 +1,40 @@ @oid4vci Feature: Manage OID4VCI credential issuer -Scenario: Successfully create credential issuer + Scenario: Successfully create credential issuer When Issuer creates an oid4vci issuer Then Issuer sees the oid4vci issuer exists on the agent And Issuer sees the oid4vci issuer on IssuerMetadata endpoint -Scenario: Successfully update credential issuer + Scenario: Successfully update credential issuer Given Issuer has an existing oid4vci issuer When Issuer updates the oid4vci issuer Then Issuer sees the oid4vci issuer updated with new values And Issuer sees the oid4vci IssuerMetadata endpoint updated with new values -Scenario: Successfully delete credential issuer + Scenario: Successfully delete credential issuer Given Issuer has an existing oid4vci issuer When Issuer deletes the oid4vci issuer Then Issuer cannot see the oid4vci issuer on the agent And Issuer cannot see the oid4vci IssuerMetadata endpoint + + Scenario Outline: Create issuer with expect response + When Issuer tries to create oid4vci issuer with '', '', '' and '' + Then Issuer should see the oid4vci '' http status response with '' detail + Examples: + | id | url | clientId | clientSecret | httpStatus | errorDetail | description | + | null | null | null | null | 400 | authorizationServer.url | null values | + | null | malformed | id | secret | 400 | Relative URL 'malformed' is not | malformed url | + | null | http://example.com | id | null | 400 | authorizationServer.clientSecret | null client secret | + | null | http://example.com | null | secret | 400 | authorizationServer.clientId | null client id | + | null | null | id | secret | 400 | authorizationServer.url | null url | + | 4048ef76-749d-4296-8c6c-07c8a20733a0 | http://example.com | id | secret | 201 | | right values | + | 4048ef76-749d-4296-8c6c-07c8a20733a0 | http://example.com | id | secret | 500 | | duplicated id | + + Scenario Outline: Update issuer with expect response + Given Issuer has an existing oid4vci issuer + When Issuer tries to update the oid4vci issuer '' property using '' value + Then Issuer should see the oid4vci '' http status response with '' detail + Examples: + | property | value | httpStatus | errorDetail | description | + | url | malformed | 404 | | Invalid URL | From c2da492131e5c545b0fefb101246c48684bc9433 Mon Sep 17 00:00:00 2001 From: Yurii Shynbuiev Date: Wed, 9 Oct 2024 14:05:13 +0700 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20adjust=20Kotlin=20and=20TypeScript?= =?UTF-8?q?=20HTTP=20client=20to=20use=20the=20`schemaId`=20f=E2=80=A6=20(?= =?UTF-8?q?#1388)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Yurii Shynbuiev --- .github/workflows/lint.yml | 2 +- .mega-linter.yml | 2 + .../client/generator/openapitools.json | 2 +- cloud-agent/client/generator/package.json | 2 +- cloud-agent/client/generator/yarn.lock | 254 +++++++------ .../client/kotlin/.openapi-generator-ignore | 7 + .../kotlin/gradle/wrapper/gradle-wrapper.jar | Bin 62076 -> 43462 bytes .../gradle/wrapper/gradle-wrapper.properties | 3 +- cloud-agent/client/kotlin/gradlew | 176 ++++----- cloud-agent/client/kotlin/gradlew.bat | 341 +++++++++++++----- cloud-agent/client/kotlin/settings.gradle | 3 +- .../adapters/StringOrStringArrayAdapter.kt | 33 ++ .../CreateIssueCredentialRecordRequest.kt | 86 +++++ .../identus/client/models/Service.kt | 31 +- .../typescript/.openapi-generator-ignore | 1 + .../CreateIssueCredentialRecordRequest.ts | 135 +++++++ tests/integration-tests/build.gradle.kts | 2 +- .../steps/credentials/JwtCredentialSteps.kt | 2 +- .../agent-performance-tests-k6/.env | 4 +- .../agent-performance-tests-k6/package.json | 2 +- .../agent-performance-tests-k6/yarn.lock | 8 +- 21 files changed, 768 insertions(+), 328 deletions(-) create mode 100644 cloud-agent/client/kotlin/src/main/kotlin/org/hyperledger/identus/client/adapters/StringOrStringArrayAdapter.kt create mode 100644 cloud-agent/client/kotlin/src/main/kotlin/org/hyperledger/identus/client/models/CreateIssueCredentialRecordRequest.kt create mode 100644 cloud-agent/client/typescript/models/CreateIssueCredentialRecordRequest.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b14905dbbf..4ec94cfb7a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,7 +36,7 @@ jobs: - name: MegaLinter id: ml - uses: oxsecurity/megalinter@v7.1.0 + uses: oxsecurity/megalinter@v8 - name: Archive production artifacts if: success() || failure() diff --git a/.mega-linter.yml b/.mega-linter.yml index 2e4d3c64b2..d065f68b12 100644 --- a/.mega-linter.yml +++ b/.mega-linter.yml @@ -12,6 +12,7 @@ DISABLE_LINTERS: - REPOSITORY_CHECKOV - REPOSITORY_SECRETLINT - REPOSITORY_KICS + - REPOSITORY_GRYPE - SCALA_SCALAFIX - SQL_TSQLLINT - C_CPPLINT # For pollux/lib/anoncreds/src/main/c @@ -30,6 +31,7 @@ DISABLE_LINTERS: DISABLE_ERRORS_LINTERS: - KOTLIN_KTLINT + - KOTLIN_DETEKT - PROTOBUF_PROTOLINT - MARKDOWN_MARKDOWN_LINK_CHECK - ACTION_ACTIONLINT diff --git a/cloud-agent/client/generator/openapitools.json b/cloud-agent/client/generator/openapitools.json index 5571688218..f227cf2df3 100644 --- a/cloud-agent/client/generator/openapitools.json +++ b/cloud-agent/client/generator/openapitools.json @@ -2,6 +2,6 @@ "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", "spaces": 2, "generator-cli": { - "version": "7.4.0" + "version": "7.7.0" } } diff --git a/cloud-agent/client/generator/package.json b/cloud-agent/client/generator/package.json index 79c2bf504e..f9fb3d43dc 100644 --- a/cloud-agent/client/generator/package.json +++ b/cloud-agent/client/generator/package.json @@ -13,7 +13,7 @@ "publish:clients": "./publish-clients.sh" }, "dependencies": { - "@openapitools/openapi-generator-cli": "2.7.0", + "@openapitools/openapi-generator-cli": "2.13.13", "npm-run-all": "^4.1.5" } } diff --git a/cloud-agent/client/generator/yarn.lock b/cloud-agent/client/generator/yarn.lock index faf654ad56..b9236b8e57 100644 --- a/cloud-agent/client/generator/yarn.lock +++ b/cloud-agent/client/generator/yarn.lock @@ -14,33 +14,31 @@ resolved "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz" integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA== -"@nestjs/axios@0.1.0": - version "0.1.0" - resolved "https://registry.npmjs.org/@nestjs/axios/-/axios-0.1.0.tgz" - integrity sha512-b2TT2X6BFbnNoeteiaxCIiHaFcSbVW+S5yygYqiIq5i6H77yIU3IVuLdpQkHq8/EqOWFwMopLN8jdkUT71Am9w== - dependencies: - axios "0.27.2" +"@nestjs/axios@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@nestjs/axios/-/axios-3.0.3.tgz#a663cb13cff07ea6b9a7107263de2ae472d41118" + integrity sha512-h6TCn3yJwD6OKqqqfmtRS5Zo4E46Ip2n+gK1sqwzNBC+qxQ9xpCu+ODVRFur6V3alHSCSBxb3nNtt73VEdluyA== -"@nestjs/common@9.3.11": - version "9.3.11" - resolved "https://registry.npmjs.org/@nestjs/common/-/common-9.3.11.tgz" - integrity sha512-IFZ2G/5UKWC2Uo7tJ4SxGed2+aiA+sJyWeWsGTogKVDhq90oxVBToh+uCDeI31HNUpqYGoWmkletfty42zUd8A== +"@nestjs/common@10.4.3": + version "10.4.3" + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.3.tgz#b9059313d928aea335a4a185a621e32c1858c845" + integrity sha512-4hbLd3XIJubHSylYd/1WSi4VQvG68KM/ECYpMDqA3k3J1/T17SAg40sDoq3ZoO5OZgU0xuNyjuISdOTjs11qVg== dependencies: - uid "2.0.1" + uid "2.0.2" iterare "1.2.1" - tslib "2.5.0" + tslib "2.7.0" -"@nestjs/core@9.3.11": - version "9.3.11" - resolved "https://registry.npmjs.org/@nestjs/core/-/core-9.3.11.tgz" - integrity sha512-CI27a2JFd5rvvbgkalWqsiwQNhcP4EAG5BUK8usjp29wVp1kx30ghfBT8FLqIgmkRVo65A0IcEnWsxeXMntkxQ== +"@nestjs/core@10.4.3": + version "10.4.3" + resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.4.3.tgz#b2a3dcfc6a948a74618feeee8affc3186afe52da" + integrity sha512-6OQz+5C8mT8yRtfvE5pPCq+p6w5jDot+oQku1KzQ24ABn+lay1KGuJwcKZhdVNuselx+8xhdMxknZTA8wrGLIg== dependencies: - uid "2.0.1" + uid "2.0.2" "@nuxtjs/opencollective" "0.3.2" fast-safe-stringify "2.1.1" iterare "1.2.1" - path-to-regexp "3.2.0" - tslib "2.5.0" + path-to-regexp "3.3.0" + tslib "2.7.0" "@nuxtjs/opencollective@0.3.2": version "0.3.2" @@ -51,27 +49,36 @@ consola "^2.15.0" node-fetch "^2.6.1" -"@openapitools/openapi-generator-cli@2.7.0": - version "2.7.0" - resolved "https://registry.npmjs.org/@openapitools/openapi-generator-cli/-/openapi-generator-cli-2.7.0.tgz" - integrity sha512-ieEpHTA/KsDz7ANw03lLPYyjdedDEXYEyYoGBRWdduqXWSX65CJtttjqa8ZaB1mNmIjMtchUHwAYQmTLVQ8HYg== +"@openapitools/openapi-generator-cli@2.13.13": + version "2.13.13" + resolved "https://registry.yarnpkg.com/@openapitools/openapi-generator-cli/-/openapi-generator-cli-2.13.13.tgz#380fd9556500b558f066a9ee0c46678f7803422b" + integrity sha512-uioqbxB6TfiLoOEE3T8kqTn/ffaRzOwS3ATMQnoMvh2lwADKMT6bDLfE3YO3XTEj+HflXcsLXQGK6PLiqa8Mmw== dependencies: - "@nestjs/axios" "0.1.0" - "@nestjs/common" "9.3.11" - "@nestjs/core" "9.3.11" + "@nestjs/axios" "3.0.3" + "@nestjs/common" "10.4.3" + "@nestjs/core" "10.4.3" "@nuxtjs/opencollective" "0.3.2" + axios "1.7.7" chalk "4.1.2" commander "8.3.0" compare-versions "4.1.4" concurrently "6.5.1" console.table "0.10.0" fs-extra "10.1.0" - glob "7.1.6" - inquirer "8.2.5" + glob "9.3.5" + https-proxy-agent "7.0.5" + inquirer "8.2.6" lodash "4.17.21" reflect-metadata "0.1.13" - rxjs "7.8.0" - tslib "2.0.3" + rxjs "7.8.1" + tslib "2.7.0" + +agent-base@^7.0.2: + version "7.1.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317" + integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== + dependencies: + debug "^4.3.4" ansi-escapes@^4.2.1: version "4.3.2" @@ -129,13 +136,14 @@ available-typed-arrays@^1.0.5: resolved "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz" integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== -axios@0.27.2: - version "0.27.2" - resolved "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz" - integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== +axios@1.7.7: + version "1.7.7" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f" + integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q== dependencies: - follow-redirects "^1.14.9" + follow-redirects "^1.15.6" form-data "^4.0.0" + proxy-from-env "^1.1.0" balanced-match@^1.0.0: version "1.0.2" @@ -164,6 +172,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + buffer@^5.5.0: version "5.7.1" resolved "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz" @@ -323,6 +338,13 @@ date-fns@^2.16.1: dependencies: "@babel/runtime" "^7.21.0" +debug@4, debug@^4.3.4: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + defaults@^1.0.3: version "1.0.4" resolved "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz" @@ -456,10 +478,10 @@ figures@^3.0.0: dependencies: escape-string-regexp "^1.0.5" -follow-redirects@^1.14.9: - version "1.15.6" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" - integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== +follow-redirects@^1.15.6: + version "1.15.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== for-each@^0.3.3: version "0.3.3" @@ -534,17 +556,15 @@ get-symbol-description@^1.0.0: call-bind "^1.0.2" get-intrinsic "^1.1.1" -glob@7.1.6: - version "7.1.6" - resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== +glob@9.3.5: + version "9.3.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-9.3.5.tgz#ca2ed8ca452781a3009685607fdf025a899dfe21" + integrity sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q== dependencies: fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" + minimatch "^8.0.2" + minipass "^4.2.4" + path-scurry "^1.6.1" globalthis@^1.0.3: version "1.0.3" @@ -616,6 +636,14 @@ hosted-git-info@^2.1.4: resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz" integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== +https-proxy-agent@7.0.5: + version "7.0.5" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz#9e8b5013873299e11fab6fd548405da2d6c602b2" + integrity sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw== + dependencies: + agent-base "^7.0.2" + debug "4" + iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" @@ -628,23 +656,15 @@ ieee754@^1.1.13: resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@^2.0.3, inherits@^2.0.4: +inherits@^2.0.3, inherits@^2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -inquirer@8.2.5: - version "8.2.5" - resolved "https://registry.npmjs.org/inquirer/-/inquirer-8.2.5.tgz" - integrity sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ== +inquirer@8.2.6: + version "8.2.6" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.6.tgz#733b74888195d8d400a67ac332011b5fae5ea562" + integrity sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg== dependencies: ansi-escapes "^4.2.1" chalk "^4.1.1" @@ -660,7 +680,7 @@ inquirer@8.2.5: string-width "^4.1.0" strip-ansi "^6.0.0" through "^2.3.6" - wrap-ansi "^7.0.0" + wrap-ansi "^6.0.1" internal-slot@^1.0.5: version "1.0.5" @@ -841,6 +861,11 @@ log-symbols@^4.1.0: chalk "^4.1.0" is-unicode-supported "^0.1.0" +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + memorystream@^0.3.1: version "0.3.1" resolved "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz" @@ -870,6 +895,28 @@ minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" +minimatch@^8.0.2: + version "8.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-8.0.4.tgz#847c1b25c014d4e9a7f68aaf63dedd668a626229" + integrity sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA== + dependencies: + brace-expansion "^2.0.1" + +minipass@^4.2.4: + version "4.2.8" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.2.8.tgz#f0010f64393ecfc1d1ccb5f582bcaf45f48e1a3a" + integrity sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0": + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + mute-stream@0.0.8: version "0.0.8" resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz" @@ -932,13 +979,6 @@ object.assign@^4.1.4: has-symbols "^1.0.3" object-keys "^1.1.1" -once@^1.3.0: - version "1.4.0" - resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - onetime@^5.1.0: version "5.1.2" resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz" @@ -974,11 +1014,6 @@ parse-json@^4.0.0: error-ex "^1.3.1" json-parse-better-errors "^1.0.1" -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" - integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - path-key@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz" @@ -989,10 +1024,18 @@ path-parse@^1.0.7: resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-to-regexp@3.2.0: - version "3.2.0" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz" - integrity sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA== +path-scurry@^1.6.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-to-regexp@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.3.0.tgz#f7f31d32e8518c2660862b644414b6d5c63a611b" + integrity sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw== path-type@^3.0.0: version "3.0.0" @@ -1011,6 +1054,11 @@ pify@^3.0.0: resolved "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz" integrity sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg== +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + read-pkg@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz" @@ -1075,10 +1123,10 @@ run-async@^2.4.0: resolved "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz" integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== -rxjs@7.8.0: - version "7.8.0" - resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz" - integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg== +rxjs@7.8.1, rxjs@^7.5.5: + version "7.8.1" + resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz" + integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== dependencies: tslib "^2.1.0" @@ -1089,13 +1137,6 @@ rxjs@^6.6.3: dependencies: tslib "^1.9.0" -rxjs@^7.5.5: - version "7.8.1" - resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz" - integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== - dependencies: - tslib "^2.1.0" - safe-array-concat@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.0.tgz" @@ -1304,15 +1345,10 @@ tree-kill@^1.2.2: resolved "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz" integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== -tslib@2.0.3: - version "2.0.3" - resolved "https://registry.npmjs.org/tslib/-/tslib-2.0.3.tgz" - integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ== - -tslib@2.5.0: - version "2.5.0" - resolved "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz" - integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== +tslib@2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" + integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== tslib@^1.9.0: version "1.14.1" @@ -1368,10 +1404,10 @@ typed-array-length@^1.0.4: for-each "^0.3.3" is-typed-array "^1.1.9" -uid@2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/uid/-/uid-2.0.1.tgz" - integrity sha512-PF+1AnZgycpAIEmNtjxGBVmKbZAQguaa4pBUq6KNaGEcpzZ2klCNZLM34tsjp76maN00TttiiUf6zkIBpJQm2A== +uid@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/uid/-/uid-2.0.2.tgz#4b5782abf0f2feeefc00fa88006b2b3b7af3e3b9" + integrity sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g== dependencies: "@lukeed/csprng" "^1.0.0" @@ -1452,6 +1488,15 @@ which@^1.2.9: dependencies: isexe "^2.0.0" +wrap-ansi@^6.0.1: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" @@ -1461,11 +1506,6 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrappy@1: - version "1.0.2" - resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - y18n@^5.0.5: version "5.0.8" resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz" diff --git a/cloud-agent/client/kotlin/.openapi-generator-ignore b/cloud-agent/client/kotlin/.openapi-generator-ignore index d9ad2d6500..658834d27c 100644 --- a/cloud-agent/client/kotlin/.openapi-generator-ignore +++ b/cloud-agent/client/kotlin/.openapi-generator-ignore @@ -17,6 +17,9 @@ src/main/kotlin/org/hyperledger/identus/client/models/DateTimeParameter.kt src/main/kotlin/org/hyperledger/identus/client/models/DidParameter.kt src/main/kotlin/org/hyperledger/identus/client/models/VcVerificationParameter.kt +src/main/kotlin/org/hyperledger/identus/client/models/CreateIssueCredentialRecordRequest.kt +src/main/kotlin/org/hyperledger/identus/client/models/CreateIssueCredentialRecordRequestSchemaId.kt + src/test/kotlin/org/hyperledger/identus/client/models/UpdateManagedDIDServiceActionTest.kt src/test/kotlin/org/hyperledger/identus/client/models/UpdateManagedDIDServiceActionTypeTest.kt @@ -25,3 +28,7 @@ src/test/kotlin/org/hyperledger/identus/client/models/ServiceTypeTest.kt src/test/kotlin/org/hyperledger/identus/client/models/StatusPurposeTest.kt src/test/kotlin/org/hyperledger/identus/client/models/CredentialSubjectTest.kt + +src/test/kotlin/org/hyperledger/identus/client/models/CreateIssueCredentialRecordRequestTest.kt +src/test/kotlin/org/hyperledger/identus/client/models/CreateIssueCredentialRecordRequestSchemaIdTest.kt + diff --git a/cloud-agent/client/kotlin/gradle/wrapper/gradle-wrapper.jar b/cloud-agent/client/kotlin/gradle/wrapper/gradle-wrapper.jar index c1962a79e29d3e0ab67b14947c167a862655af9b..d64cd4917707c1f8861d8cb53dd15194d4248596 100644 GIT binary patch literal 43462 zcma&NWl&^owk(X(xVyW%ySuwf;qI=D6|RlDJ2cR^yEKh!@I- zp9QeisK*rlxC>+~7Dk4IxIRsKBHqdR9b3+fyL=ynHmIDe&|>O*VlvO+%z5;9Z$|DJ zb4dO}-R=MKr^6EKJiOrJdLnCJn>np?~vU-1sSFgPu;pthGwf}bG z(1db%xwr#x)r+`4AGu$j7~u2MpVs3VpLp|mx&;>`0p0vH6kF+D2CY0fVdQOZ@h;A` z{infNyvmFUiu*XG}RNMNwXrbec_*a3N=2zJ|Wh5z* z5rAX$JJR{#zP>KY**>xHTuw?|-Rg|o24V)74HcfVT;WtQHXlE+_4iPE8QE#DUm%x0 zEKr75ur~W%w#-My3Tj`hH6EuEW+8K-^5P62$7Sc5OK+22qj&Pd1;)1#4tKihi=~8C zHiQSst0cpri6%OeaR`PY>HH_;CPaRNty%WTm4{wDK8V6gCZlG@U3$~JQZ;HPvDJcT1V{ z?>H@13MJcCNe#5z+MecYNi@VT5|&UiN1D4ATT+%M+h4c$t;C#UAs3O_q=GxK0}8%8 z8J(_M9bayxN}69ex4dzM_P3oh@ZGREjVvn%%r7=xjkqxJP4kj}5tlf;QosR=%4L5y zWhgejO=vao5oX%mOHbhJ8V+SG&K5dABn6!WiKl{|oPkq(9z8l&Mm%(=qGcFzI=eLu zWc_oCLyf;hVlB@dnwY98?75B20=n$>u3b|NB28H0u-6Rpl((%KWEBOfElVWJx+5yg z#SGqwza7f}$z;n~g%4HDU{;V{gXIhft*q2=4zSezGK~nBgu9-Q*rZ#2f=Q}i2|qOp z!!y4p)4o=LVUNhlkp#JL{tfkhXNbB=Ox>M=n6soptJw-IDI|_$is2w}(XY>a=H52d z3zE$tjPUhWWS+5h=KVH&uqQS=$v3nRs&p$%11b%5qtF}S2#Pc`IiyBIF4%A!;AVoI zXU8-Rpv!DQNcF~(qQnyyMy=-AN~U>#&X1j5BLDP{?K!%h!;hfJI>$mdLSvktEr*89 zdJHvby^$xEX0^l9g$xW-d?J;L0#(`UT~zpL&*cEh$L|HPAu=P8`OQZV!-}l`noSp_ zQ-1$q$R-gDL)?6YaM!=8H=QGW$NT2SeZlb8PKJdc=F-cT@j7Xags+Pr*jPtlHFnf- zh?q<6;)27IdPc^Wdy-mX%2s84C1xZq9Xms+==F4);O`VUASmu3(RlgE#0+#giLh-& zcxm3_e}n4{%|X zJp{G_j+%`j_q5}k{eW&TlP}J2wtZ2^<^E(O)4OQX8FDp6RJq!F{(6eHWSD3=f~(h} zJXCf7=r<16X{pHkm%yzYI_=VDP&9bmI1*)YXZeB}F? z(%QsB5fo*FUZxK$oX~X^69;x~j7ms8xlzpt-T15e9}$4T-pC z6PFg@;B-j|Ywajpe4~bk#S6(fO^|mm1hKOPfA%8-_iGCfICE|=P_~e;Wz6my&)h_~ zkv&_xSAw7AZ%ThYF(4jADW4vg=oEdJGVOs>FqamoL3Np8>?!W#!R-0%2Bg4h?kz5I zKV-rKN2n(vUL%D<4oj@|`eJ>0i#TmYBtYmfla;c!ATW%;xGQ0*TW@PTlGG><@dxUI zg>+3SiGdZ%?5N=8uoLA|$4isK$aJ%i{hECP$bK{J#0W2gQ3YEa zZQ50Stn6hqdfxJ*9#NuSLwKFCUGk@c=(igyVL;;2^wi4o30YXSIb2g_ud$ zgpCr@H0qWtk2hK8Q|&wx)}4+hTYlf;$a4#oUM=V@Cw#!$(nOFFpZ;0lc!qd=c$S}Z zGGI-0jg~S~cgVT=4Vo)b)|4phjStD49*EqC)IPwyeKBLcN;Wu@Aeph;emROAwJ-0< z_#>wVm$)ygH|qyxZaet&(Vf%pVdnvKWJn9`%DAxj3ot;v>S$I}jJ$FLBF*~iZ!ZXE zkvui&p}fI0Y=IDX)mm0@tAd|fEHl~J&K}ZX(Mm3cm1UAuwJ42+AO5@HwYfDH7ipIc zmI;1J;J@+aCNG1M`Btf>YT>~c&3j~Qi@Py5JT6;zjx$cvOQW@3oQ>|}GH?TW-E z1R;q^QFjm5W~7f}c3Ww|awg1BAJ^slEV~Pk`Kd`PS$7;SqJZNj->it4DW2l15}xP6 zoCl$kyEF%yJni0(L!Z&14m!1urXh6Btj_5JYt1{#+H8w?5QI%% zo-$KYWNMJVH?Hh@1n7OSu~QhSswL8x0=$<8QG_zepi_`y_79=nK=_ZP_`Em2UI*tyQoB+r{1QYZCpb?2OrgUw#oRH$?^Tj!Req>XiE#~B|~ z+%HB;=ic+R@px4Ld8mwpY;W^A%8%l8$@B@1m5n`TlKI6bz2mp*^^^1mK$COW$HOfp zUGTz-cN9?BGEp}5A!mDFjaiWa2_J2Iq8qj0mXzk; z66JBKRP{p%wN7XobR0YjhAuW9T1Gw3FDvR5dWJ8ElNYF94eF3ebu+QwKjtvVu4L zI9ip#mQ@4uqVdkl-TUQMb^XBJVLW(-$s;Nq;@5gr4`UfLgF$adIhd?rHOa%D);whv z=;krPp~@I+-Z|r#s3yCH+c1US?dnm+C*)r{m+86sTJusLdNu^sqLrfWed^ndHXH`m zd3#cOe3>w-ga(Dus_^ppG9AC>Iq{y%%CK+Cro_sqLCs{VLuK=dev>OL1dis4(PQ5R zcz)>DjEkfV+MO;~>VUlYF00SgfUo~@(&9$Iy2|G0T9BSP?&T22>K46D zL*~j#yJ?)^*%J3!16f)@Y2Z^kS*BzwfAQ7K96rFRIh>#$*$_Io;z>ux@}G98!fWR@ zGTFxv4r~v)Gsd|pF91*-eaZ3Qw1MH$K^7JhWIdX%o$2kCbvGDXy)a?@8T&1dY4`;L z4Kn+f%SSFWE_rpEpL9bnlmYq`D!6F%di<&Hh=+!VI~j)2mfil03T#jJ_s?}VV0_hp z7T9bWxc>Jm2Z0WMU?`Z$xE74Gu~%s{mW!d4uvKCx@WD+gPUQ zV0vQS(Ig++z=EHN)BR44*EDSWIyT~R4$FcF*VEY*8@l=218Q05D2$|fXKFhRgBIEE zdDFB}1dKkoO^7}{5crKX!p?dZWNz$m>1icsXG2N+((x0OIST9Zo^DW_tytvlwXGpn zs8?pJXjEG;T@qrZi%#h93?FP$!&P4JA(&H61tqQi=opRzNpm zkrG}$^t9&XduK*Qa1?355wd8G2CI6QEh@Ua>AsD;7oRUNLPb76m4HG3K?)wF~IyS3`fXuNM>${?wmB zpVz;?6_(Fiadfd{vUCBM*_kt$+F3J+IojI;9L(gc9n3{sEZyzR9o!_mOwFC#tQ{Q~ zP3-`#uK#tP3Q7~Q;4H|wjZHO8h7e4IuBxl&vz2w~D8)w=Wtg31zpZhz%+kzSzL*dV zwp@{WU4i;hJ7c2f1O;7Mz6qRKeASoIv0_bV=i@NMG*l<#+;INk-^`5w@}Dj~;k=|}qM1vq_P z|GpBGe_IKq|LNy9SJhKOQ$c=5L{Dv|Q_lZl=-ky*BFBJLW9&y_C|!vyM~rQx=!vun z?rZJQB5t}Dctmui5i31C_;_}CEn}_W%>oSXtt>@kE1=JW*4*v4tPp;O6 zmAk{)m!)}34pTWg8{i>($%NQ(Tl;QC@J@FfBoc%Gr&m560^kgSfodAFrIjF}aIw)X zoXZ`@IsMkc8_=w%-7`D6Y4e*CG8k%Ud=GXhsTR50jUnm+R*0A(O3UKFg0`K;qp1bl z7``HN=?39ic_kR|^R^~w-*pa?Vj#7|e9F1iRx{GN2?wK!xR1GW!qa=~pjJb-#u1K8 zeR?Y2i-pt}yJq;SCiVHODIvQJX|ZJaT8nO+(?HXbLefulKKgM^B(UIO1r+S=7;kLJ zcH}1J=Px2jsh3Tec&v8Jcbng8;V-`#*UHt?hB(pmOipKwf3Lz8rG$heEB30Sg*2rx zV<|KN86$soN(I!BwO`1n^^uF2*x&vJ$2d$>+`(romzHP|)K_KkO6Hc>_dwMW-M(#S zK(~SiXT1@fvc#U+?|?PniDRm01)f^#55;nhM|wi?oG>yBsa?~?^xTU|fX-R(sTA+5 zaq}-8Tx7zrOy#3*JLIIVsBmHYLdD}!0NP!+ITW+Thn0)8SS!$@)HXwB3tY!fMxc#1 zMp3H?q3eD?u&Njx4;KQ5G>32+GRp1Ee5qMO0lZjaRRu&{W<&~DoJNGkcYF<5(Ab+J zgO>VhBl{okDPn78<%&e2mR{jwVCz5Og;*Z;;3%VvoGo_;HaGLWYF7q#jDX=Z#Ml`H z858YVV$%J|e<1n`%6Vsvq7GmnAV0wW4$5qQ3uR@1i>tW{xrl|ExywIc?fNgYlA?C5 zh$ezAFb5{rQu6i7BSS5*J-|9DQ{6^BVQ{b*lq`xS@RyrsJN?-t=MTMPY;WYeKBCNg z^2|pN!Q^WPJuuO4!|P@jzt&tY1Y8d%FNK5xK(!@`jO2aEA*4 zkO6b|UVBipci?){-Ke=+1;mGlND8)6+P;8sq}UXw2hn;fc7nM>g}GSMWu&v&fqh

iViYT=fZ(|3Ox^$aWPp4a8h24tD<|8-!aK0lHgL$N7Efw}J zVIB!7=T$U`ao1?upi5V4Et*-lTG0XvExbf!ya{cua==$WJyVG(CmA6Of*8E@DSE%L z`V^$qz&RU$7G5mg;8;=#`@rRG`-uS18$0WPN@!v2d{H2sOqP|!(cQ@ zUHo!d>>yFArLPf1q`uBvY32miqShLT1B@gDL4XoVTK&@owOoD)OIHXrYK-a1d$B{v zF^}8D3Y^g%^cnvScOSJR5QNH+BI%d|;J;wWM3~l>${fb8DNPg)wrf|GBP8p%LNGN# z3EaIiItgwtGgT&iYCFy9-LG}bMI|4LdmmJt@V@% zb6B)1kc=T)(|L@0;wr<>=?r04N;E&ef+7C^`wPWtyQe(*pD1pI_&XHy|0gIGHMekd zF_*M4yi6J&Z4LQj65)S zXwdM{SwUo%3SbPwFsHgqF@V|6afT|R6?&S;lw=8% z3}@9B=#JI3@B*#4s!O))~z zc>2_4Q_#&+5V`GFd?88^;c1i7;Vv_I*qt!_Yx*n=;rj!82rrR2rQ8u5(Ejlo{15P% zs~!{%XJ>FmJ})H^I9bn^Re&38H{xA!0l3^89k(oU;bZWXM@kn$#aoS&Y4l^-WEn-fH39Jb9lA%s*WsKJQl?n9B7_~P z-XM&WL7Z!PcoF6_D>V@$CvUIEy=+Z&0kt{szMk=f1|M+r*a43^$$B^MidrT0J;RI` z(?f!O<8UZkm$_Ny$Hth1J#^4ni+im8M9mr&k|3cIgwvjAgjH z8`N&h25xV#v*d$qBX5jkI|xOhQn!>IYZK7l5#^P4M&twe9&Ey@@GxYMxBZq2e7?`q z$~Szs0!g{2fGcp9PZEt|rdQ6bhAgpcLHPz?f-vB?$dc*!9OL?Q8mn7->bFD2Si60* z!O%y)fCdMSV|lkF9w%x~J*A&srMyYY3{=&$}H zGQ4VG_?$2X(0|vT0{=;W$~icCI{b6W{B!Q8xdGhF|D{25G_5_+%s(46lhvNLkik~R z>nr(&C#5wwOzJZQo9m|U<;&Wk!_#q|V>fsmj1g<6%hB{jGoNUPjgJslld>xmODzGjYc?7JSuA?A_QzjDw5AsRgi@Y|Z0{F{!1=!NES-#*f^s4l0Hu zz468))2IY5dmD9pa*(yT5{EyP^G>@ZWumealS-*WeRcZ}B%gxq{MiJ|RyX-^C1V=0 z@iKdrGi1jTe8Ya^x7yyH$kBNvM4R~`fbPq$BzHum-3Zo8C6=KW@||>zsA8-Y9uV5V z#oq-f5L5}V<&wF4@X@<3^C%ptp6+Ce)~hGl`kwj)bsAjmo_GU^r940Z-|`<)oGnh7 zFF0Tde3>ui?8Yj{sF-Z@)yQd~CGZ*w-6p2U<8}JO-sRsVI5dBji`01W8A&3$?}lxBaC&vn0E$c5tW* zX>5(zzZ=qn&!J~KdsPl;P@bmA-Pr8T*)eh_+Dv5=Ma|XSle6t(k8qcgNyar{*ReQ8 zTXwi=8vr>!3Ywr+BhggHDw8ke==NTQVMCK`$69fhzEFB*4+H9LIvdt-#IbhZvpS}} zO3lz;P?zr0*0$%-Rq_y^k(?I{Mk}h@w}cZpMUp|ucs55bcloL2)($u%mXQw({Wzc~ z;6nu5MkjP)0C(@%6Q_I_vsWrfhl7Zpoxw#WoE~r&GOSCz;_ro6i(^hM>I$8y>`!wW z*U^@?B!MMmb89I}2(hcE4zN2G^kwyWCZp5JG>$Ez7zP~D=J^LMjSM)27_0B_X^C(M z`fFT+%DcKlu?^)FCK>QzSnV%IsXVcUFhFdBP!6~se&xxrIxsvySAWu++IrH;FbcY$ z2DWTvSBRfLwdhr0nMx+URA$j3i7_*6BWv#DXfym?ZRDcX9C?cY9sD3q)uBDR3uWg= z(lUIzB)G$Hr!){>E{s4Dew+tb9kvToZp-1&c?y2wn@Z~(VBhqz`cB;{E4(P3N2*nJ z_>~g@;UF2iG{Kt(<1PyePTKahF8<)pozZ*xH~U-kfoAayCwJViIrnqwqO}7{0pHw$ zs2Kx?s#vQr7XZ264>5RNKSL8|Ty^=PsIx^}QqOOcfpGUU4tRkUc|kc7-!Ae6!+B{o~7nFpm3|G5^=0#Bnm6`V}oSQlrX(u%OWnC zoLPy&Q;1Jui&7ST0~#+}I^&?vcE*t47~Xq#YwvA^6^} z`WkC)$AkNub|t@S!$8CBlwbV~?yp&@9h{D|3z-vJXgzRC5^nYm+PyPcgRzAnEi6Q^gslXYRv4nycsy-SJu?lMps-? zV`U*#WnFsdPLL)Q$AmD|0`UaC4ND07+&UmOu!eHruzV|OUox<+Jl|Mr@6~C`T@P%s zW7sgXLF2SSe9Fl^O(I*{9wsFSYb2l%-;&Pi^dpv!{)C3d0AlNY6!4fgmSgj_wQ*7Am7&$z;Jg&wgR-Ih;lUvWS|KTSg!&s_E9_bXBkZvGiC6bFKDWZxsD$*NZ#_8bl zG1P-#@?OQzED7@jlMJTH@V!6k;W>auvft)}g zhoV{7$q=*;=l{O>Q4a@ ziMjf_u*o^PsO)#BjC%0^h>Xp@;5$p{JSYDt)zbb}s{Kbt!T*I@Pk@X0zds6wsefuU zW$XY%yyRGC94=6mf?x+bbA5CDQ2AgW1T-jVAJbm7K(gp+;v6E0WI#kuACgV$r}6L? zd|Tj?^%^*N&b>Dd{Wr$FS2qI#Ucs1yd4N+RBUQiSZGujH`#I)mG&VKoDh=KKFl4=G z&MagXl6*<)$6P}*Tiebpz5L=oMaPrN+caUXRJ`D?=K9!e0f{@D&cZLKN?iNP@X0aF zE(^pl+;*T5qt?1jRC=5PMgV!XNITRLS_=9{CJExaQj;lt!&pdzpK?8p>%Mb+D z?yO*uSung=-`QQ@yX@Hyd4@CI^r{2oiu`%^bNkz+Nkk!IunjwNC|WcqvX~k=><-I3 zDQdbdb|!v+Iz01$w@aMl!R)koD77Xp;eZwzSl-AT zr@Vu{=xvgfq9akRrrM)}=!=xcs+U1JO}{t(avgz`6RqiiX<|hGG1pmop8k6Q+G_mv zJv|RfDheUp2L3=^C=4aCBMBn0aRCU(DQwX-W(RkRwmLeuJYF<0urcaf(=7)JPg<3P zQs!~G)9CT18o!J4{zX{_e}4eS)U-E)0FAt}wEI(c0%HkxgggW;(1E=>J17_hsH^sP z%lT0LGgbUXHx-K*CI-MCrP66UP0PvGqM$MkeLyqHdbgP|_Cm!7te~b8p+e6sQ_3k| zVcwTh6d83ltdnR>D^)BYQpDKlLk3g0Hdcgz2}%qUs9~~Rie)A-BV1mS&naYai#xcZ z(d{8=-LVpTp}2*y)|gR~;qc7fp26}lPcLZ#=JpYcn3AT9(UIdOyg+d(P5T7D&*P}# zQCYplZO5|7+r19%9e`v^vfSS1sbX1c%=w1;oyruXB%Kl$ACgKQ6=qNWLsc=28xJjg zwvsI5-%SGU|3p>&zXVl^vVtQT3o-#$UT9LI@Npz~6=4!>mc431VRNN8od&Ul^+G_kHC`G=6WVWM z%9eWNyy(FTO|A+@x}Ou3CH)oi;t#7rAxdIXfNFwOj_@Y&TGz6P_sqiB`Q6Lxy|Q{`|fgmRG(k+!#b*M+Z9zFce)f-7;?Km5O=LHV9f9_87; zF7%R2B+$?@sH&&-$@tzaPYkw0;=i|;vWdI|Wl3q_Zu>l;XdIw2FjV=;Mq5t1Q0|f< zs08j54Bp`3RzqE=2enlkZxmX6OF+@|2<)A^RNQpBd6o@OXl+i)zO%D4iGiQNuXd+zIR{_lb96{lc~bxsBveIw6umhShTX+3@ZJ=YHh@ zWY3(d0azg;7oHn>H<>?4@*RQbi>SmM=JrHvIG(~BrvI)#W(EAeO6fS+}mxxcc+X~W6&YVl86W9WFSS}Vz-f9vS?XUDBk)3TcF z8V?$4Q)`uKFq>xT=)Y9mMFVTUk*NIA!0$?RP6Ig0TBmUFrq*Q-Agq~DzxjStQyJ({ zBeZ;o5qUUKg=4Hypm|}>>L=XKsZ!F$yNTDO)jt4H0gdQ5$f|d&bnVCMMXhNh)~mN z@_UV6D7MVlsWz+zM+inZZp&P4fj=tm6fX)SG5H>OsQf_I8c~uGCig$GzuwViK54bcgL;VN|FnyQl>Ed7(@>=8$a_UKIz|V6CeVSd2(P z0Uu>A8A+muM%HLFJQ9UZ5c)BSAv_zH#1f02x?h9C}@pN@6{>UiAp>({Fn(T9Q8B z^`zB;kJ5b`>%dLm+Ol}ty!3;8f1XDSVX0AUe5P#@I+FQ-`$(a;zNgz)4x5hz$Hfbg z!Q(z26wHLXko(1`;(BAOg_wShpX0ixfWq3ponndY+u%1gyX)_h=v1zR#V}#q{au6; z!3K=7fQwnRfg6FXtNQmP>`<;!N137paFS%y?;lb1@BEdbvQHYC{976l`cLqn;b8lp zIDY>~m{gDj(wfnK!lpW6pli)HyLEiUrNc%eXTil|F2s(AY+LW5hkKb>TQ3|Q4S9rr zpDs4uK_co6XPsn_z$LeS{K4jFF`2>U`tbgKdyDne`xmR<@6AA+_hPNKCOR-Zqv;xk zu5!HsBUb^!4uJ7v0RuH-7?l?}b=w5lzzXJ~gZcxRKOovSk@|#V+MuX%Y+=;14i*%{)_gSW9(#4%)AV#3__kac1|qUy!uyP{>?U#5wYNq}y$S9pCc zFc~4mgSC*G~j0u#qqp9 z${>3HV~@->GqEhr_Xwoxq?Hjn#=s2;i~g^&Hn|aDKpA>Oc%HlW(KA1?BXqpxB;Ydx)w;2z^MpjJ(Qi(X!$5RC z*P{~%JGDQqojV>2JbEeCE*OEu!$XJ>bWA9Oa_Hd;y)F%MhBRi*LPcdqR8X`NQ&1L# z5#9L*@qxrx8n}LfeB^J{%-?SU{FCwiWyHp682F+|pa+CQa3ZLzBqN1{)h4d6+vBbV zC#NEbQLC;}me3eeYnOG*nXOJZEU$xLZ1<1Y=7r0(-U0P6-AqwMAM`a(Ed#7vJkn6plb4eI4?2y3yOTGmmDQ!z9`wzbf z_OY#0@5=bnep;MV0X_;;SJJWEf^E6Bd^tVJ9znWx&Ks8t*B>AM@?;D4oWUGc z!H*`6d7Cxo6VuyS4Eye&L1ZRhrRmN6Lr`{NL(wDbif|y&z)JN>Fl5#Wi&mMIr5i;x zBx}3YfF>>8EC(fYnmpu~)CYHuHCyr5*`ECap%t@y=jD>!_%3iiE|LN$mK9>- zHdtpy8fGZtkZF?%TW~29JIAfi2jZT8>OA7=h;8T{{k?c2`nCEx9$r zS+*&vt~2o^^J+}RDG@+9&M^K*z4p{5#IEVbz`1%`m5c2};aGt=V?~vIM}ZdPECDI)47|CWBCfDWUbxBCnmYivQ*0Nu_xb*C>~C9(VjHM zxe<*D<#dQ8TlpMX2c@M<9$w!RP$hpG4cs%AI){jp*Sj|*`m)5(Bw*A0$*i-(CA5#%>a)$+jI2C9r6|(>J8InryENI z$NohnxDUB;wAYDwrb*!N3noBTKPpPN}~09SEL18tkG zxgz(RYU_;DPT{l?Q$+eaZaxnsWCA^ds^0PVRkIM%bOd|G2IEBBiz{&^JtNsODs;5z zICt_Zj8wo^KT$7Bg4H+y!Df#3mbl%%?|EXe!&(Vmac1DJ*y~3+kRKAD=Ovde4^^%~ zw<9av18HLyrf*_>Slp;^i`Uy~`mvBjZ|?Ad63yQa#YK`4+c6;pW4?XIY9G1(Xh9WO8{F-Aju+nS9Vmv=$Ac0ienZ+p9*O%NG zMZKy5?%Z6TAJTE?o5vEr0r>f>hb#2w2U3DL64*au_@P!J!TL`oH2r*{>ffu6|A7tv zL4juf$DZ1MW5ZPsG!5)`k8d8c$J$o;%EIL0va9&GzWvkS%ZsGb#S(?{!UFOZ9<$a| zY|a+5kmD5N&{vRqkgY>aHsBT&`rg|&kezoD)gP0fsNYHsO#TRc_$n6Lf1Z{?+DLziXlHrq4sf(!>O{?Tj;Eh@%)+nRE_2VxbN&&%%caU#JDU%vL3}Cb zsb4AazPI{>8H&d=jUaZDS$-0^AxE@utGs;-Ez_F(qC9T=UZX=>ok2k2 ziTn{K?y~a5reD2A)P${NoI^>JXn>`IeArow(41c-Wm~)wiryEP(OS{YXWi7;%dG9v zI?mwu1MxD{yp_rrk!j^cKM)dc4@p4Ezyo%lRN|XyD}}>v=Xoib0gOcdXrQ^*61HNj z=NP|pd>@yfvr-=m{8$3A8TQGMTE7g=z!%yt`8`Bk-0MMwW~h^++;qyUP!J~ykh1GO z(FZ59xuFR$(WE;F@UUyE@Sp>`aVNjyj=Ty>_Vo}xf`e7`F;j-IgL5`1~-#70$9_=uBMq!2&1l zomRgpD58@)YYfvLtPW}{C5B35R;ZVvB<<#)x%srmc_S=A7F@DW8>QOEGwD6suhwCg z>Pa+YyULhmw%BA*4yjDp|2{!T98~<6Yfd(wo1mQ!KWwq0eg+6)o1>W~f~kL<-S+P@$wx*zeI|1t7z#Sxr5 zt6w+;YblPQNplq4Z#T$GLX#j6yldXAqj>4gAnnWtBICUnA&-dtnlh=t0Ho_vEKwV` z)DlJi#!@nkYV#$!)@>udAU*hF?V`2$Hf=V&6PP_|r#Iv*J$9)pF@X3`k;5})9^o4y z&)~?EjX5yX12O(BsFy-l6}nYeuKkiq`u9145&3Ssg^y{5G3Pse z9w(YVa0)N-fLaBq1`P!_#>SS(8fh_5!f{UrgZ~uEdeMJIz7DzI5!NHHqQtm~#CPij z?=N|J>nPR6_sL7!f4hD_|KH`vf8(Wpnj-(gPWH+ZvID}%?~68SwhPTC3u1_cB`otq z)U?6qo!ZLi5b>*KnYHWW=3F!p%h1;h{L&(Q&{qY6)_qxNfbP6E3yYpW!EO+IW3?@J z);4>g4gnl^8klu7uA>eGF6rIGSynacogr)KUwE_R4E5Xzi*Qir@b-jy55-JPC8c~( zo!W8y9OGZ&`xmc8;=4-U9=h{vCqfCNzYirONmGbRQlR`WWlgnY+1wCXbMz&NT~9*| z6@FrzP!LX&{no2!Ln_3|I==_4`@}V?4a;YZKTdw;vT<+K+z=uWbW(&bXEaWJ^W8Td z-3&1bY^Z*oM<=M}LVt>_j+p=2Iu7pZmbXrhQ_k)ysE9yXKygFNw$5hwDn(M>H+e1&9BM5!|81vd%r%vEm zqxY3?F@fb6O#5UunwgAHR9jp_W2zZ}NGp2%mTW@(hz7$^+a`A?mb8|_G*GNMJ) zjqegXQio=i@AINre&%ofexAr95aop5C+0MZ0m-l=MeO8m3epm7U%vZB8+I+C*iNFM z#T3l`gknX;D$-`2XT^Cg*vrv=RH+P;_dfF++cP?B_msQI4j+lt&rX2)3GaJx%W*Nn zkML%D{z5tpHH=dksQ*gzc|}gzW;lwAbxoR07VNgS*-c3d&8J|;@3t^ zVUz*J*&r7DFRuFVDCJDK8V9NN5hvpgGjwx+5n)qa;YCKe8TKtdnh{I7NU9BCN!0dq zczrBk8pE{{@vJa9ywR@mq*J=v+PG;?fwqlJVhijG!3VmIKs>9T6r7MJpC)m!Tc#>g zMtVsU>wbwFJEfwZ{vB|ZlttNe83)$iz`~#8UJ^r)lJ@HA&G#}W&ZH*;k{=TavpjWE z7hdyLZPf*X%Gm}i`Y{OGeeu^~nB8=`{r#TUrM-`;1cBvEd#d!kPqIgYySYhN-*1;L z^byj%Yi}Gx)Wnkosi337BKs}+5H5dth1JA{Ir-JKN$7zC)*}hqeoD(WfaUDPT>0`- z(6sa0AoIqASwF`>hP}^|)a_j2s^PQn*qVC{Q}htR z5-)duBFXT_V56-+UohKXlq~^6uf!6sA#ttk1o~*QEy_Y-S$gAvq47J9Vtk$5oA$Ct zYhYJ@8{hsC^98${!#Ho?4y5MCa7iGnfz}b9jE~h%EAAv~Qxu)_rAV;^cygV~5r_~?l=B`zObj7S=H=~$W zPtI_m%g$`kL_fVUk9J@>EiBH zOO&jtn~&`hIFMS5S`g8w94R4H40mdNUH4W@@XQk1sr17b{@y|JB*G9z1|CrQjd+GX z6+KyURG3;!*BQrentw{B2R&@2&`2}n(z-2&X7#r!{yg@Soy}cRD~j zj9@UBW+N|4HW4AWapy4wfUI- zZ`gSL6DUlgj*f1hSOGXG0IVH8HxK?o2|3HZ;KW{K+yPAlxtb)NV_2AwJm|E)FRs&& z=c^e7bvUsztY|+f^k7NXs$o1EUq>cR7C0$UKi6IooHWlK_#?IWDkvywnzg&ThWo^? z2O_N{5X39#?eV9l)xI(>@!vSB{DLt*oY!K1R8}_?%+0^C{d9a%N4 zoxHVT1&Lm|uDX%$QrBun5e-F`HJ^T$ zmzv)p@4ZHd_w9!%Hf9UYNvGCw2TTTbrj9pl+T9%-_-}L(tES>Or-}Z4F*{##n3~L~TuxjirGuIY#H7{%$E${?p{Q01 zi6T`n;rbK1yIB9jmQNycD~yZq&mbIsFWHo|ZAChSFPQa<(%d8mGw*V3fh|yFoxOOiWJd(qvVb!Z$b88cg->N=qO*4k~6;R==|9ihg&riu#P~s4Oap9O7f%crSr^rljeIfXDEg>wi)&v*a%7zpz<9w z*r!3q9J|390x`Zk;g$&OeN&ctp)VKRpDSV@kU2Q>jtok($Y-*x8_$2piTxun81@vt z!Vj?COa0fg2RPXMSIo26T=~0d`{oGP*eV+$!0I<(4azk&Vj3SiG=Q!6mX0p$z7I}; z9BJUFgT-K9MQQ-0@Z=^7R<{bn2Fm48endsSs`V7_@%8?Bxkqv>BDoVcj?K#dV#uUP zL1ND~?D-|VGKe3Rw_7-Idpht>H6XRLh*U7epS6byiGvJpr%d}XwfusjH9g;Z98H`x zyde%%5mhGOiL4wljCaWCk-&uE4_OOccb9c!ZaWt4B(wYl!?vyzl%7n~QepN&eFUrw zFIOl9c({``6~QD+43*_tzP{f2x41h(?b43^y6=iwyB)2os5hBE!@YUS5?N_tXd=h( z)WE286Fbd>R4M^P{!G)f;h<3Q>Fipuy+d2q-)!RyTgt;wr$(?9ox3;q+{E*ZQHhOn;lM`cjnu9 zXa48ks-v(~b*;MAI<>YZH(^NV8vjb34beE<_cwKlJoR;k6lJNSP6v}uiyRD?|0w+X@o1ONrH8a$fCxXpf? z?$DL0)7|X}Oc%h^zrMKWc-NS9I0Utu@>*j}b@tJ=ixQSJ={4@854wzW@E>VSL+Y{i z#0b=WpbCZS>kUCO_iQz)LoE>P5LIG-hv9E+oG}DtlIDF>$tJ1aw9^LuhLEHt?BCj& z(O4I8v1s#HUi5A>nIS-JK{v!7dJx)^Yg%XjNmlkWAq2*cv#tHgz`Y(bETc6CuO1VkN^L-L3j_x<4NqYb5rzrLC-7uOv z!5e`GZt%B782C5-fGnn*GhDF$%(qP<74Z}3xx+{$4cYKy2ikxI7B2N+2r07DN;|-T->nU&!=Cm#rZt%O_5c&1Z%nlWq3TKAW0w zQqemZw_ue--2uKQsx+niCUou?HjD`xhEjjQd3%rrBi82crq*~#uA4+>vR<_S{~5ce z-2EIl?~s z1=GVL{NxP1N3%=AOaC}j_Fv=ur&THz zyO!d9kHq|c73kpq`$+t+8Bw7MgeR5~`d7ChYyGCBWSteTB>8WAU(NPYt2Dk`@#+}= zI4SvLlyk#pBgVigEe`?NG*vl7V6m+<}%FwPV=~PvvA)=#ths==DRTDEYh4V5}Cf$z@#;< zyWfLY_5sP$gc3LLl2x+Ii)#b2nhNXJ{R~vk`s5U7Nyu^3yFg&D%Txwj6QezMX`V(x z=C`{76*mNb!qHHs)#GgGZ_7|vkt9izl_&PBrsu@}L`X{95-2jf99K)0=*N)VxBX2q z((vkpP2RneSIiIUEnGb?VqbMb=Zia+rF~+iqslydE34cSLJ&BJW^3knX@M;t*b=EA zNvGzv41Ld_T+WT#XjDB840vovUU^FtN_)G}7v)1lPetgpEK9YS^OWFkPoE{ovj^=@ zO9N$S=G$1ecndT_=5ehth2Lmd1II-PuT~C9`XVePw$y8J#dpZ?Tss<6wtVglm(Ok7 z3?^oi@pPio6l&!z8JY(pJvG=*pI?GIOu}e^EB6QYk$#FJQ%^AIK$I4epJ+9t?KjqA+bkj&PQ*|vLttme+`9G=L% ziadyMw_7-M)hS(3E$QGNCu|o23|%O+VN7;Qggp?PB3K-iSeBa2b}V4_wY`G1Jsfz4 z9|SdB^;|I8E8gWqHKx!vj_@SMY^hLEIbSMCuE?WKq=c2mJK z8LoG-pnY!uhqFv&L?yEuxo{dpMTsmCn)95xanqBrNPTgXP((H$9N${Ow~Is-FBg%h z53;|Y5$MUN)9W2HBe2TD`ct^LHI<(xWrw}$qSoei?}s)&w$;&!14w6B6>Yr6Y8b)S z0r71`WmAvJJ`1h&poLftLUS6Ir zC$bG9!Im_4Zjse)#K=oJM9mHW1{%l8sz$1o?ltdKlLTxWWPB>Vk22czVt|1%^wnN@*!l)}?EgtvhC>vlHm^t+ogpgHI1_$1ox9e;>0!+b(tBrmXRB`PY1vp-R**8N7 zGP|QqI$m(Rdu#=(?!(N}G9QhQ%o!aXE=aN{&wtGP8|_qh+7a_j_sU5|J^)vxq;# zjvzLn%_QPHZZIWu1&mRAj;Sa_97p_lLq_{~j!M9N^1yp3U_SxRqK&JnR%6VI#^E12 z>CdOVI^_9aPK2eZ4h&^{pQs}xsijXgFYRIxJ~N7&BB9jUR1fm!(xl)mvy|3e6-B3j zJn#ajL;bFTYJ2+Q)tDjx=3IklO@Q+FFM}6UJr6km7hj7th9n_&JR7fnqC!hTZoM~T zBeaVFp%)0cbPhejX<8pf5HyRUj2>aXnXBqDJe73~J%P(2C?-RT{c3NjE`)om! zl$uewSgWkE66$Kb34+QZZvRn`fob~Cl9=cRk@Es}KQm=?E~CE%spXaMO6YmrMl%9Q zlA3Q$3|L1QJ4?->UjT&CBd!~ru{Ih^in&JXO=|<6J!&qp zRe*OZ*cj5bHYlz!!~iEKcuE|;U4vN1rk$xq6>bUWD*u(V@8sG^7>kVuo(QL@Ki;yL zWC!FT(q{E8#on>%1iAS0HMZDJg{Z{^!De(vSIq&;1$+b)oRMwA3nc3mdTSG#3uYO_ z>+x;7p4I;uHz?ZB>dA-BKl+t-3IB!jBRgdvAbW!aJ(Q{aT>+iz?91`C-xbe)IBoND z9_Xth{6?(y3rddwY$GD65IT#f3<(0o#`di{sh2gm{dw*#-Vnc3r=4==&PU^hCv$qd zjw;>i&?L*Wq#TxG$mFIUf>eK+170KG;~+o&1;Tom9}}mKo23KwdEM6UonXgc z!6N(@k8q@HPw{O8O!lAyi{rZv|DpgfU{py+j(X_cwpKqcalcqKIr0kM^%Br3SdeD> zHSKV94Yxw;pjzDHo!Q?8^0bb%L|wC;4U^9I#pd5O&eexX+Im{ z?jKnCcsE|H?{uGMqVie_C~w7GX)kYGWAg%-?8|N_1#W-|4F)3YTDC+QSq1s!DnOML3@d`mG%o2YbYd#jww|jD$gotpa)kntakp#K;+yo-_ZF9qrNZw<%#C zuPE@#3RocLgPyiBZ+R_-FJ_$xP!RzWm|aN)S+{$LY9vvN+IW~Kf3TsEIvP+B9Mtm! zpfNNxObWQpLoaO&cJh5>%slZnHl_Q~(-Tfh!DMz(dTWld@LG1VRF`9`DYKhyNv z2pU|UZ$#_yUx_B_|MxUq^glT}O5Xt(Vm4Mr02><%C)@v;vPb@pT$*yzJ4aPc_FZ3z z3}PLoMBIM>q_9U2rl^sGhk1VUJ89=*?7|v`{!Z{6bqFMq(mYiA?%KbsI~JwuqVA9$H5vDE+VocjX+G^%bieqx->s;XWlKcuv(s%y%D5Xbc9+ zc(_2nYS1&^yL*ey664&4`IoOeDIig}y-E~_GS?m;D!xv5-xwz+G`5l6V+}CpeJDi^ z%4ed$qowm88=iYG+(`ld5Uh&>Dgs4uPHSJ^TngXP_V6fPyl~>2bhi20QB%lSd#yYn zO05?KT1z@?^-bqO8Cg`;ft>ilejsw@2%RR7;`$Vs;FmO(Yr3Fp`pHGr@P2hC%QcA|X&N2Dn zYf`MqXdHi%cGR@%y7Rg7?d3?an){s$zA{!H;Ie5exE#c~@NhQUFG8V=SQh%UxUeiV zd7#UcYqD=lk-}sEwlpu&H^T_V0{#G?lZMxL7ih_&{(g)MWBnCZxtXg znr#}>U^6!jA%e}@Gj49LWG@*&t0V>Cxc3?oO7LSG%~)Y5}f7vqUUnQ;STjdDU}P9IF9d9<$;=QaXc zL1^X7>fa^jHBu_}9}J~#-oz3Oq^JmGR#?GO7b9a(=R@fw@}Q{{@`Wy1vIQ#Bw?>@X z-_RGG@wt|%u`XUc%W{J z>iSeiz8C3H7@St3mOr_mU+&bL#Uif;+Xw-aZdNYUpdf>Rvu0i0t6k*}vwU`XNO2he z%miH|1tQ8~ZK!zmL&wa3E;l?!!XzgV#%PMVU!0xrDsNNZUWKlbiOjzH-1Uoxm8E#r`#2Sz;-o&qcqB zC-O_R{QGuynW14@)7&@yw1U}uP(1cov)twxeLus0s|7ayrtT8c#`&2~Fiu2=R;1_4bCaD=*E@cYI>7YSnt)nQc zohw5CsK%m?8Ack)qNx`W0_v$5S}nO|(V|RZKBD+btO?JXe|~^Qqur%@eO~<8-L^9d z=GA3-V14ng9L29~XJ>a5k~xT2152zLhM*@zlp2P5Eu}bywkcqR;ISbas&#T#;HZSf z2m69qTV(V@EkY(1Dk3`}j)JMo%ZVJ*5eB zYOjIisi+igK0#yW*gBGj?@I{~mUOvRFQR^pJbEbzFxTubnrw(Muk%}jI+vXmJ;{Q6 zrSobKD>T%}jV4Ub?L1+MGOD~0Ir%-`iTnWZN^~YPrcP5y3VMAzQ+&en^VzKEb$K!Q z<7Dbg&DNXuow*eD5yMr+#08nF!;%4vGrJI++5HdCFcGLfMW!KS*Oi@=7hFwDG!h2< zPunUEAF+HncQkbfFj&pbzp|MU*~60Z(|Ik%Tn{BXMN!hZOosNIseT?R;A`W?=d?5X zK(FB=9mZusYahp|K-wyb={rOpdn=@;4YI2W0EcbMKyo~-#^?h`BA9~o285%oY zfifCh5Lk$SY@|2A@a!T2V+{^!psQkx4?x0HSV`(w9{l75QxMk!)U52Lbhn{8ol?S) zCKo*7R(z!uk<6*qO=wh!Pul{(qq6g6xW;X68GI_CXp`XwO zxuSgPRAtM8K7}5E#-GM!*ydOOG_{A{)hkCII<|2=ma*71ci_-}VPARm3crFQjLYV! z9zbz82$|l01mv`$WahE2$=fAGWkd^X2kY(J7iz}WGS z@%MyBEO=A?HB9=^?nX`@nh;7;laAjs+fbo!|K^mE!tOB>$2a_O0y-*uaIn8k^6Y zSbuv;5~##*4Y~+y7Z5O*3w4qgI5V^17u*ZeupVGH^nM&$qmAk|anf*>r zWc5CV;-JY-Z@Uq1Irpb^O`L_7AGiqd*YpGUShb==os$uN3yYvb`wm6d=?T*it&pDk zo`vhw)RZX|91^^Wa_ti2zBFyWy4cJu#g)_S6~jT}CC{DJ_kKpT`$oAL%b^!2M;JgT zM3ZNbUB?}kP(*YYvXDIH8^7LUxz5oE%kMhF!rnPqv!GiY0o}NR$OD=ITDo9r%4E>E0Y^R(rS^~XjWyVI6 zMOR5rPXhTp*G*M&X#NTL`Hu*R+u*QNoiOKg4CtNPrjgH>c?Hi4MUG#I917fx**+pJfOo!zFM&*da&G_x)L(`k&TPI*t3e^{crd zX<4I$5nBQ8Ax_lmNRa~E*zS-R0sxkz`|>7q_?*e%7bxqNm3_eRG#1ae3gtV9!fQpY z+!^a38o4ZGy9!J5sylDxZTx$JmG!wg7;>&5H1)>f4dXj;B+@6tMlL=)cLl={jLMxY zbbf1ax3S4>bwB9-$;SN2?+GULu;UA-35;VY*^9Blx)Jwyb$=U!D>HhB&=jSsd^6yw zL)?a|>GxU!W}ocTC(?-%z3!IUhw^uzc`Vz_g>-tv)(XA#JK^)ZnC|l1`@CdX1@|!| z_9gQ)7uOf?cR@KDp97*>6X|;t@Y`k_N@)aH7gY27)COv^P3ya9I{4z~vUjLR9~z1Z z5=G{mVtKH*&$*t0@}-i_v|3B$AHHYale7>E+jP`ClqG%L{u;*ff_h@)al?RuL7tOO z->;I}>%WI{;vbLP3VIQ^iA$4wl6@0sDj|~112Y4OFjMs`13!$JGkp%b&E8QzJw_L5 zOnw9joc0^;O%OpF$Qp)W1HI!$4BaXX84`%@#^dk^hFp^pQ@rx4g(8Xjy#!X%+X5Jd@fs3amGT`}mhq#L97R>OwT5-m|h#yT_-v@(k$q7P*9X~T*3)LTdzP!*B} z+SldbVWrrwQo9wX*%FyK+sRXTa@O?WM^FGWOE?S`R(0P{<6p#f?0NJvnBia?k^fX2 zNQs7K-?EijgHJY}&zsr;qJ<*PCZUd*x|dD=IQPUK_nn)@X4KWtqoJNHkT?ZWL_hF? zS8lp2(q>;RXR|F;1O}EE#}gCrY~#n^O`_I&?&z5~7N;zL0)3Tup`%)oHMK-^r$NT% zbFg|o?b9w(q@)6w5V%si<$!U<#}s#x@0aX-hP>zwS#9*75VXA4K*%gUc>+yzupTDBOKH8WR4V0pM(HrfbQ&eJ79>HdCvE=F z|J>s;;iDLB^3(9}?biKbxf1$lI!*Z%*0&8UUq}wMyPs_hclyQQi4;NUY+x2qy|0J; zhn8;5)4ED1oHwg+VZF|80<4MrL97tGGXc5Sw$wAI#|2*cvQ=jB5+{AjMiDHmhUC*a zlmiZ`LAuAn_}hftXh;`Kq0zblDk8?O-`tnilIh|;3lZp@F_osJUV9`*R29M?7H{Fy z`nfVEIDIWXmU&YW;NjU8)EJpXhxe5t+scf|VXM!^bBlwNh)~7|3?fWwo_~ZFk(22% zTMesYw+LNx3J-_|DM~`v93yXe=jPD{q;li;5PD?Dyk+b? zo21|XpT@)$BM$%F=P9J19Vi&1#{jM3!^Y&fr&_`toi`XB1!n>sbL%U9I5<7!@?t)~ z;&H%z>bAaQ4f$wIzkjH70;<8tpUoxzKrPhn#IQfS%9l5=Iu))^XC<58D!-O z{B+o5R^Z21H0T9JQ5gNJnqh#qH^na|z92=hONIM~@_iuOi|F>jBh-?aA20}Qx~EpDGElELNn~|7WRXRFnw+Wdo`|# zBpU=Cz3z%cUJ0mx_1($X<40XEIYz(`noWeO+x#yb_pwj6)R(__%@_Cf>txOQ74wSJ z0#F3(zWWaR-jMEY$7C*3HJrohc79>MCUu26mfYN)f4M~4gD`}EX4e}A!U}QV8!S47 z6y-U-%+h`1n`*pQuKE%Av0@)+wBZr9mH}@vH@i{v(m-6QK7Ncf17x_D=)32`FOjjo zg|^VPf5c6-!FxN{25dvVh#fog=NNpXz zfB$o+0jbRkHH{!TKhE709f+jI^$3#v1Nmf80w`@7-5$1Iv_`)W^px8P-({xwb;D0y z7LKDAHgX<84?l!I*Dvi2#D@oAE^J|g$3!)x1Ua;_;<@#l1fD}lqU2_tS^6Ht$1Wl} zBESo7o^)9-Tjuz$8YQSGhfs{BQV6zW7dA?0b(Dbt=UnQs&4zHfe_sj{RJ4uS-vQpC zX;Bbsuju4%!o8?&m4UZU@~ZZjeFF6ex2ss5_60_JS_|iNc+R0GIjH1@Z z=rLT9%B|WWgOrR7IiIwr2=T;Ne?30M!@{%Qf8o`!>=s<2CBpCK_TWc(DX51>e^xh8 z&@$^b6CgOd7KXQV&Y4%}_#uN*mbanXq(2=Nj`L7H7*k(6F8s6{FOw@(DzU`4-*77{ zF+dxpv}%mFpYK?>N_2*#Y?oB*qEKB}VoQ@bzm>ptmVS_EC(#}Lxxx730trt0G)#$b zE=wVvtqOct1%*9}U{q<)2?{+0TzZzP0jgf9*)arV)*e!f`|jgT{7_9iS@e)recI#z zbzolURQ+TOzE!ymqvBY7+5NnAbWxvMLsLTwEbFqW=CPyCsmJ}P1^V30|D5E|p3BC5 z)3|qgw@ra7aXb-wsa|l^in~1_fm{7bS9jhVRkYVO#U{qMp z)Wce+|DJ}4<2gp8r0_xfZpMo#{Hl2MfjLcZdRB9(B(A(f;+4s*FxV{1F|4d`*sRNd zp4#@sEY|?^FIJ;tmH{@keZ$P(sLh5IdOk@k^0uB^BWr@pk6mHy$qf&~rI>P*a;h0C{%oA*i!VjWn&D~O#MxN&f@1Po# zKN+ zrGrkSjcr?^R#nGl<#Q722^wbYcgW@{+6CBS<1@%dPA8HC!~a`jTz<`g_l5N1M@9wn9GOAZ>nqNgq!yOCbZ@1z`U_N`Z>}+1HIZxk*5RDc&rd5{3qjRh8QmT$VyS;jK z;AF+r6XnnCp=wQYoG|rT2@8&IvKq*IB_WvS%nt%e{MCFm`&W*#LXc|HrD?nVBo=(8*=Aq?u$sDA_sC_RPDUiQ+wnIJET8vx$&fxkW~kP9qXKt zozR)@xGC!P)CTkjeWvXW5&@2?)qt)jiYWWBU?AUtzAN}{JE1I)dfz~7$;}~BmQF`k zpn11qmObXwRB8&rnEG*#4Xax3XBkKlw(;tb?Np^i+H8m(Wyz9k{~ogba@laiEk;2! zV*QV^6g6(QG%vX5Um#^sT&_e`B1pBW5yVth~xUs#0}nv?~C#l?W+9Lsb_5)!71rirGvY zTIJ$OPOY516Y|_014sNv+Z8cc5t_V=i>lWV=vNu#!58y9Zl&GsMEW#pPYPYGHQ|;vFvd*9eM==$_=vc7xnyz0~ zY}r??$<`wAO?JQk@?RGvkWVJlq2dk9vB(yV^vm{=NVI8dhsX<)O(#nr9YD?I?(VmQ z^r7VfUBn<~p3()8yOBjm$#KWx!5hRW)5Jl7wY@ky9lNM^jaT##8QGVsYeaVywmpv>X|Xj7gWE1Ezai&wVLt3p)k4w~yrskT-!PR!kiyQlaxl(( zXhF%Q9x}1TMt3~u@|#wWm-Vq?ZerK={8@~&@9r5JW}r#45#rWii};t`{5#&3$W)|@ zbAf2yDNe0q}NEUvq_Quq3cTjcw z@H_;$hu&xllCI9CFDLuScEMg|x{S7GdV8<&Mq=ezDnRZAyX-8gv97YTm0bg=d)(>N z+B2FcqvI9>jGtnK%eO%y zoBPkJTk%y`8TLf4)IXPBn`U|9>O~WL2C~C$z~9|0m*YH<-vg2CD^SX#&)B4ngOSG$ zV^wmy_iQk>dfN@Pv(ckfy&#ak@MLC7&Q6Ro#!ezM*VEh`+b3Jt%m(^T&p&WJ2Oqvj zs-4nq0TW6cv~(YI$n0UkfwN}kg3_fp?(ijSV#tR9L0}l2qjc7W?i*q01=St0eZ=4h zyGQbEw`9OEH>NMuIe)hVwYHsGERWOD;JxEiO7cQv%pFCeR+IyhwQ|y@&^24k+|8fD zLiOWFNJ2&vu2&`Jv96_z-Cd5RLgmeY3*4rDOQo?Jm`;I_(+ejsPM03!ly!*Cu}Cco zrQSrEDHNyzT(D5s1rZq!8#?f6@v6dB7a-aWs(Qk>N?UGAo{gytlh$%_IhyL7h?DLXDGx zgxGEBQoCAWo-$LRvM=F5MTle`M})t3vVv;2j0HZY&G z22^iGhV@uaJh(XyyY%} zd4iH_UfdV#T=3n}(Lj^|n;O4|$;xhu*8T3hR1mc_A}fK}jfZ7LX~*n5+`8N2q#rI$ z@<_2VANlYF$vIH$ zl<)+*tIWW78IIINA7Rr7i{<;#^yzxoLNkXL)eSs=%|P>$YQIh+ea_3k z_s7r4%j7%&*NHSl?R4k%1>Z=M9o#zxY!n8sL5>BO-ZP;T3Gut>iLS@U%IBrX6BA3k z)&@q}V8a{X<5B}K5s(c(LQ=%v1ocr`t$EqqY0EqVjr65usa=0bkf|O#ky{j3)WBR(((L^wmyHRzoWuL2~WTC=`yZ zn%VX`L=|Ok0v7?s>IHg?yArBcync5rG#^+u)>a%qjES%dRZoIyA8gQ;StH z1Ao7{<&}6U=5}4v<)1T7t!J_CL%U}CKNs-0xWoTTeqj{5{?Be$L0_tk>M9o8 zo371}S#30rKZFM{`H_(L`EM9DGp+Mifk&IP|C2Zu_)Ghr4Qtpmkm1osCf@%Z$%t+7 zYH$Cr)Ro@3-QDeQJ8m+x6%;?YYT;k6Z0E-?kr>x33`H%*ueBD7Zx~3&HtWn0?2Wt} zTG}*|v?{$ajzt}xPzV%lL1t-URi8*Zn)YljXNGDb>;!905Td|mpa@mHjIH%VIiGx- zd@MqhpYFu4_?y5N4xiHn3vX&|e6r~Xt> zZG`aGq|yTNjv;9E+Txuoa@A(9V7g?1_T5FzRI;!=NP1Kqou1z5?%X~Wwb{trRfd>i z8&y^H)8YnKyA_Fyx>}RNmQIczT?w2J4SNvI{5J&}Wto|8FR(W;Qw#b1G<1%#tmYzQ zQ2mZA-PAdi%RQOhkHy9Ea#TPSw?WxwL@H@cbkZwIq0B!@ns}niALidmn&W?!Vd4Gj zO7FiuV4*6Mr^2xlFSvM;Cp_#r8UaqIzHJQg_z^rEJw&OMm_8NGAY2)rKvki|o1bH~ z$2IbfVeY2L(^*rMRU1lM5Y_sgrDS`Z??nR2lX;zyR=c%UyGb*%TC-Dil?SihkjrQy~TMv6;BMs7P8il`H7DmpVm@rJ;b)hW)BL)GjS154b*xq-NXq2cwE z^;VP7ua2pxvCmxrnqUYQMH%a%nHmwmI33nJM(>4LznvY*k&C0{8f*%?zggpDgkuz&JBx{9mfb@wegEl2v!=}Sq2Gaty0<)UrOT0{MZtZ~j5y&w zXlYa_jY)I_+VA-^#mEox#+G>UgvM!Ac8zI<%JRXM_73Q!#i3O|)lOP*qBeJG#BST0 zqohi)O!|$|2SeJQo(w6w7%*92S})XfnhrH_Z8qe!G5>CglP=nI7JAOW?(Z29;pXJ9 zR9`KzQ=WEhy*)WH>$;7Cdz|>*i>=##0bB)oU0OR>>N<21e4rMCHDemNi2LD>Nc$;& zQRFthpWniC1J6@Zh~iJCoLOxN`oCKD5Q4r%ynwgUKPlIEd#?QViIqovY|czyK8>6B zSP%{2-<;%;1`#0mG^B(8KbtXF;Nf>K#Di72UWE4gQ%(_26Koiad)q$xRL~?pN71ZZ zujaaCx~jXjygw;rI!WB=xrOJO6HJ!!w}7eiivtCg5K|F6$EXa)=xUC za^JXSX98W`7g-tm@uo|BKj39Dl;sg5ta;4qjo^pCh~{-HdLl6qI9Ix6f$+qiZ$}s= zNguKrU;u+T@ko(Vr1>)Q%h$?UKXCY>3se%&;h2osl2D zE4A9bd7_|^njDd)6cI*FupHpE3){4NQ*$k*cOWZ_?CZ>Z4_fl@n(mMnYK62Q1d@+I zr&O))G4hMihgBqRIAJkLdk(p(D~X{-oBUA+If@B}j& zsHbeJ3RzTq96lB7d($h$xTeZ^gP0c{t!Y0c)aQE;$FY2!mACg!GDEMKXFOPI^)nHZ z`aSPJpvV0|bbrzhWWkuPURlDeN%VT8tndV8?d)eN*i4I@u zVKl^6{?}A?P)Fsy?3oi#clf}L18t;TjNI2>eI&(ezDK7RyqFxcv%>?oxUlonv(px) z$vnPzRH`y5A(x!yOIfL0bmgeMQB$H5wenx~!ujQK*nUBW;@Em&6Xv2%s(~H5WcU2R z;%Nw<$tI)a`Ve!>x+qegJnQsN2N7HaKzrFqM>`6R*gvh%O*-%THt zrB$Nk;lE;z{s{r^PPm5qz(&lM{sO*g+W{sK+m3M_z=4=&CC>T`{X}1Vg2PEfSj2x_ zmT*(x;ov%3F?qoEeeM>dUn$a*?SIGyO8m806J1W1o+4HRhc2`9$s6hM#qAm zChQ87b~GEw{ADfs+5}FJ8+|bIlIv(jT$Ap#hSHoXdd9#w<#cA<1Rkq^*EEkknUd4& zoIWIY)sAswy6fSERVm&!SO~#iN$OgOX*{9@_BWFyJTvC%S++ilSfCrO(?u=Dc?CXZ zzCG&0yVR{Z`|ZF0eEApWEo#s9osV>F{uK{QA@BES#&;#KsScf>y zvs?vIbI>VrT<*!;XmQS=bhq%46-aambZ(8KU-wOO2=en~D}MCToB_u;Yz{)1ySrPZ z@=$}EvjTdzTWU7c0ZI6L8=yP+YRD_eMMos}b5vY^S*~VZysrkq<`cK3>>v%uy7jgq z0ilW9KjVDHLv0b<1K_`1IkbTOINs0=m-22c%M~l=^S}%hbli-3?BnNq?b`hx^HX2J zIe6ECljRL0uBWb`%{EA=%!i^4sMcj+U_TaTZRb+~GOk z^ZW!nky0n*Wb*r+Q|9H@ml@Z5gU&W`(z4-j!OzC1wOke`TRAYGZVl$PmQ16{3196( zO*?`--I}Qf(2HIwb2&1FB^!faPA2=sLg(@6P4mN)>Dc3i(B0;@O-y2;lM4akD>@^v z=u>*|!s&9zem70g7zfw9FXl1bpJW(C#5w#uy5!V?Q(U35A~$dR%LDVnq@}kQm13{} zd53q3N(s$Eu{R}k2esbftfjfOITCL;jWa$}(mmm}d(&7JZ6d3%IABCapFFYjdEjdK z&4Edqf$G^MNAtL=uCDRs&Fu@FXRgX{*0<(@c3|PNHa>L%zvxWS={L8%qw`STm+=Rd zA}FLspESSIpE_^41~#5yI2bJ=9`oc;GIL!JuW&7YetZ?0H}$$%8rW@*J37L-~Rsx!)8($nI4 zZhcZ2^=Y+p4YPl%j!nFJA|*M^gc(0o$i3nlphe+~-_m}jVkRN{spFs(o0ajW@f3K{ zDV!#BwL322CET$}Y}^0ixYj2w>&Xh12|R8&yEw|wLDvF!lZ#dOTHM9pK6@Nm-@9Lnng4ZHBgBSrr7KI8YCC9DX5Kg|`HsiwJHg2(7#nS;A{b3tVO?Z% za{m5b3rFV6EpX;=;n#wltDv1LE*|g5pQ+OY&*6qCJZc5oDS6Z6JD#6F)bWxZSF@q% z+1WV;m!lRB!n^PC>RgQCI#D1br_o^#iPk>;K2hB~0^<~)?p}LG%kigm@moD#q3PE+ zA^Qca)(xnqw6x>XFhV6ku9r$E>bWNrVH9fum0?4s?Rn2LG{Vm_+QJHse6xa%nzQ?k zKug4PW~#Gtb;#5+9!QBgyB@q=sk9=$S{4T>wjFICStOM?__fr+Kei1 z3j~xPqW;W@YkiUM;HngG!;>@AITg}vAE`M2Pj9Irl4w1fo4w<|Bu!%rh%a(Ai^Zhi zs92>v5;@Y(Zi#RI*ua*h`d_7;byQSa*v9E{2x$<-_=5Z<7{%)}4XExANcz@rK69T0x3%H<@frW>RA8^swA+^a(FxK| zFl3LD*ImHN=XDUkrRhp6RY5$rQ{bRgSO*(vEHYV)3Mo6Jy3puiLmU&g82p{qr0F?ohmbz)f2r{X2|T2 z$4fdQ=>0BeKbiVM!e-lIIs8wVTuC_m7}y4A_%ikI;Wm5$9j(^Y z(cD%U%k)X>_>9~t8;pGzL6L-fmQO@K; zo&vQzMlgY95;1BSkngY)e{`n0!NfVgf}2mB3t}D9@*N;FQ{HZ3Pb%BK6;5#-O|WI( zb6h@qTLU~AbVW#_6?c!?Dj65Now7*pU{h!1+eCV^KCuPAGs28~3k@ueL5+u|Z-7}t z9|lskE`4B7W8wMs@xJa{#bsCGDFoRSNSnmNYB&U7 zVGKWe%+kFB6kb)e;TyHfqtU6~fRg)f|>=5(N36)0+C z`hv65J<$B}WUc!wFAb^QtY31yNleq4dzmG`1wHTj=c*=hay9iD071Hc?oYoUk|M*_ zU1GihAMBsM@5rUJ(qS?9ZYJ6@{bNqJ`2Mr+5#hKf?doa?F|+^IR!8lq9)wS3tF_9n zW_?hm)G(M+MYb?V9YoX^_mu5h-LP^TL^!Q9Z7|@sO(rg_4+@=PdI)WL(B7`!K^ND- z-uIuVDCVEdH_C@c71YGYT^_Scf_dhB8Z2Xy6vGtBSlYud9vggOqv^L~F{BraSE_t} zIkP+Hp2&nH^-MNEs}^`oMLy11`PQW$T|K(`Bu*(f@)mv1-qY(_YG&J2M2<7k;;RK~ zL{Fqj9yCz8(S{}@c)S!65aF<=&eLI{hAMErCx&>i7OeDN>okvegO87OaG{Jmi<|}D zaT@b|0X{d@OIJ7zvT>r+eTzgLq~|Dpu)Z&db-P4z*`M$UL51lf>FLlq6rfG)%doyp z)3kk_YIM!03eQ8Vu_2fg{+osaEJPtJ-s36R+5_AEG12`NG)IQ#TF9c@$99%0iye+ zUzZ57=m2)$D(5Nx!n)=5Au&O0BBgwxIBaeI(mro$#&UGCr<;C{UjJVAbVi%|+WP(a zL$U@TYCxJ=1{Z~}rnW;7UVb7+ZnzgmrogDxhjLGo>c~MiJAWs&&;AGg@%U?Y^0JhL ze(x6Z74JG6FlOFK(T}SXQfhr}RIFl@QXKnIcXYF)5|V~e-}suHILKT-k|<*~Ij|VF zC;t@=uj=hot~*!C68G8hTA%8SzOfETOXQ|3FSaIEjvBJp(A)7SWUi5!Eu#yWgY+;n zlm<$+UDou*V+246_o#V4kMdto8hF%%Lki#zPh}KYXmMf?hrN0;>Mv%`@{0Qn`Ujp) z=lZe+13>^Q!9zT);H<(#bIeRWz%#*}sgUX9P|9($kexOyKIOc`dLux}c$7It4u|Rl z6SSkY*V~g_B-hMPo_ak>>z@AVQ(_N)VY2kB3IZ0G(iDUYw+2d7W^~(Jq}KY=JnWS( z#rzEa&0uNhJ>QE8iiyz;n2H|SV#Og+wEZv=f2%1ELX!SX-(d3tEj$5$1}70Mp<&eI zCkfbByL7af=qQE@5vDVxx1}FSGt_a1DoE3SDI+G)mBAna)KBG4p8Epxl9QZ4BfdAN zFnF|Y(umr;gRgG6NLQ$?ZWgllEeeq~z^ZS7L?<(~O&$5|y)Al^iMKy}&W+eMm1W z7EMU)u^ke(A1#XCV>CZ71}P}0x)4wtHO8#JRG3MA-6g=`ZM!FcICCZ{IEw8Dm2&LQ z1|r)BUG^0GzI6f946RrBlfB1Vs)~8toZf~7)+G;pv&XiUO(%5bm)pl=p>nV^o*;&T z;}@oZSibzto$arQgfkp|z4Z($P>dTXE{4O=vY0!)kDO* zGF8a4wq#VaFpLfK!iELy@?-SeRrdz%F*}hjKcA*y@mj~VD3!it9lhRhX}5YOaR9$} z3mS%$2Be7{l(+MVx3 z(4?h;P!jnRmX9J9sYN#7i=iyj_5q7n#X(!cdqI2lnr8T$IfOW<_v`eB!d9xY1P=2q&WtOXY=D9QYteP)De?S4}FK6#6Ma z=E*V+#s8>L;8aVroK^6iKo=MH{4yEZ_>N-N z`(|;aOATba1^asjxlILk<4}f~`39dBFlxj>Dw(hMYKPO3EEt1@S`1lxFNM+J@uB7T zZ8WKjz7HF1-5&2=l=fqF-*@>n5J}jIxdDwpT?oKM3s8Nr`x8JnN-kCE?~aM1H!hAE z%%w(3kHfGwMnMmNj(SU(w42OrC-euI>Dsjk&jz3ts}WHqmMpzQ3vZrsXrZ|}+MHA7 z068obeXZTsO*6RS@o3x80E4ok``rV^Y3hr&C1;|ZZ0|*EKO`$lECUYG2gVFtUTw)R z4Um<0ZzlON`zTdvVdL#KFoMFQX*a5wM0Czp%wTtfK4Sjs)P**RW&?lP$(<}q%r68Z zS53Y!d@&~ne9O)A^tNrXHhXBkj~$8j%pT1%%mypa9AW5E&s9)rjF4@O3ytH{0z6riz|@< zB~UPh*wRFg2^7EbQrHf0y?E~dHlkOxof_a?M{LqQ^C!i2dawHTPYUE=X@2(3<=OOxs8qn_(y>pU>u^}3y&df{JarR0@VJn0f+U%UiF=$Wyq zQvnVHESil@d|8&R<%}uidGh7@u^(%?$#|&J$pvFC-n8&A>utA=n3#)yMkz+qnG3wd zP7xCnF|$9Dif@N~L)Vde3hW8W!UY0BgT2v(wzp;tlLmyk2%N|0jfG$%<;A&IVrOI< z!L)o>j>;dFaqA3pL}b-Je(bB@VJ4%!JeX@3x!i{yIeIso^=n?fDX`3bU=eG7sTc%g%ye8$v8P@yKE^XD=NYxTb zbf!Mk=h|otpqjFaA-vs5YOF-*GwWPc7VbaOW&stlANnCN8iftFMMrUdYNJ_Bnn5Vt zxfz@Ah|+4&P;reZxp;MmEI7C|FOv8NKUm8njF7Wb6Gi7DeODLl&G~}G4be&*Hi0Qw z5}77vL0P+7-B%UL@3n1&JPxW^d@vVwp?u#gVcJqY9#@-3X{ok#UfW3<1fb%FT`|)V~ggq z(3AUoUS-;7)^hCjdT0Kf{i}h)mBg4qhtHHBti=~h^n^OTH5U*XMgDLIR@sre`AaB$ zg)IGBET_4??m@cx&c~bA80O7B8CHR7(LX7%HThkeC*@vi{-pL%e)yXp!B2InafbDF zjPXf1mko3h59{lT6EEbxKO1Z5GF71)WwowO6kY|6tjSVSWdQ}NsK2x{>i|MKZK8%Q zfu&_0D;CO-Jg0#YmyfctyJ!mRJp)e#@O0mYdp|8x;G1%OZQ3Q847YWTyy|%^cpA;m zze0(5p{tMu^lDkpe?HynyO?a1$_LJl2L&mpeKu%8YvgRNr=%2z${%WThHG=vrWY@4 zsA`OP#O&)TetZ>s%h!=+CE15lOOls&nvC~$Qz0Ph7tHiP;O$i|eDwpT{cp>+)0-|; zY$|bB+Gbel>5aRN3>c0x)4U=|X+z+{ zn*_p*EQoquRL+=+p;=lm`d71&1NqBz&_ph)MXu(Nv6&XE7(RsS)^MGj5Q?Fwude-(sq zjJ>aOq!7!EN>@(fK7EE#;i_BGvli`5U;r!YA{JRodLBc6-`n8K+Fjgwb%sX;j=qHQ z7&Tr!)!{HXoO<2BQrV9Sw?JRaLXV8HrsNevvnf>Y-6|{T!pYLl7jp$-nEE z#X!4G4L#K0qG_4Z;Cj6=;b|Be$hi4JvMH!-voxqx^@8cXp`B??eFBz2lLD8RRaRGh zn7kUfy!YV~p(R|p7iC1Rdgt$_24i0cd-S8HpG|`@my70g^y`gu%#Tf_L21-k?sRRZHK&at(*ED0P8iw{7?R$9~OF$Ko;Iu5)ur5<->x!m93Eb zFYpIx60s=Wxxw=`$aS-O&dCO_9?b1yKiPCQmSQb>T)963`*U+Ydj5kI(B(B?HNP8r z*bfSBpSu)w(Z3j7HQoRjUG(+d=IaE~tv}y14zHHs|0UcN52fT8V_<@2ep_ee{QgZG zmgp8iv4V{k;~8@I%M3<#B;2R>Ef(Gg_cQM7%}0s*^)SK6!Ym+~P^58*wnwV1BW@eG z4sZLqsUvBbFsr#8u7S1r4teQ;t)Y@jnn_m5jS$CsW1um!p&PqAcc8!zyiXHVta9QC zY~wCwCF0U%xiQPD_INKtTb;A|Zf29(mu9NI;E zc-e>*1%(LSXB`g}kd`#}O;veb<(sk~RWL|f3ljxCnEZDdNSTDV6#Td({6l&y4IjKF z^}lIUq*ZUqgTPumD)RrCN{M^jhY>E~1pn|KOZ5((%F)G|*ZQ|r4zIbrEiV%42hJV8 z3xS)=!X1+=olbdGJ=yZil?oXLct8FM{(6ikLL3E%=q#O6(H$p~gQu6T8N!plf!96| z&Q3=`L~>U0zZh;z(pGR2^S^{#PrPxTRHD1RQOON&f)Siaf`GLj#UOk&(|@0?zm;Sx ztsGt8=29-MZs5CSf1l1jNFtNt5rFNZxJPvkNu~2}7*9468TWm>nN9TP&^!;J{-h)_ z7WsHH9|F%I`Pb!>KAS3jQWKfGivTVkMJLO-HUGM_a4UQ_%RgL6WZvrW+Z4ujZn;y@ zz9$=oO!7qVTaQAA^BhX&ZxS*|5dj803M=k&2%QrXda`-Q#IoZL6E(g+tN!6CA!CP* zCpWtCujIea)ENl0liwVfj)Nc<9mV%+e@=d`haoZ*`B7+PNjEbXBkv=B+Pi^~L#EO$D$ZqTiD8f<5$eyb54-(=3 zh)6i8i|jp(@OnRrY5B8t|LFXFQVQ895n*P16cEKTrT*~yLH6Z4e*bZ5otpRDri&+A zfNbK1D5@O=sm`fN=WzWyse!za5n%^+6dHPGX#8DyIK>?9qyX}2XvBWVqbP%%D)7$= z=#$WulZlZR<{m#gU7lwqK4WS1Ne$#_P{b17qe$~UOXCl>5b|6WVh;5vVnR<%d+Lnp z$uEmML38}U4vaW8>shm6CzB(Wei3s#NAWE3)a2)z@i{4jTn;;aQS)O@l{rUM`J@K& l00vQ5JBs~;vo!vr%%-k{2_Fq1Mn4QF81S)AQ99zk{{c4yR+0b! literal 62076 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 diff --git a/cloud-agent/client/kotlin/gradle/wrapper/gradle-wrapper.properties b/cloud-agent/client/kotlin/gradle/wrapper/gradle-wrapper.properties index 8707e8b506..e7646dead0 100644 --- a/cloud-agent/client/kotlin/gradle/wrapper/gradle-wrapper.properties +++ b/cloud-agent/client/kotlin/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/cloud-agent/client/kotlin/gradlew b/cloud-agent/client/kotlin/gradlew index aeb74cbb43..9d0ce634cb 100755 --- a/cloud-agent/client/kotlin/gradlew +++ b/cloud-agent/client/kotlin/gradlew @@ -69,34 +69,35 @@ app_path=$0 # Need this for daisy-chained symlinks. while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] +APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path +[ -h "$app_path" ] do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac +ls=$( ls -ld "$app_path" ) +link=${ls#*' -> '} +case $link in #( +/*) app_path=$link ;; #( +*) app_path=$APP_HOME$link ;; +esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { - echo "$*" +echo "$*" } >&2 die () { - echo - echo "$*" - echo - exit 1 +echo +echo "$*" +echo +exit 1 } >&2 # OS specific support (must be 'true' or 'false'). @@ -105,10 +106,10 @@ msys=false darwin=false nonstop=false case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; +CYGWIN* ) cygwin=true ;; #( +Darwin* ) darwin=true ;; #( +MSYS* | MINGW* ) msys=true ;; #( +NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -116,43 +117,46 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME +if [ -x "$JAVA_HOME/jre/sh/java" ] ; then +# IBM's JDK on AIX uses strange locations for the executables +JAVACMD=$JAVA_HOME/jre/sh/java +else +JAVACMD=$JAVA_HOME/bin/java +fi +if [ ! -x "$JAVACMD" ] ; then +die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." - fi +fi else - JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +JAVACMD=java +if ! command -v java >/dev/null 2>&1 +then +die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi +fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac +case $MAX_FD in #( +max*) +# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. +# shellcheck disable=SC2039,SC3045 +MAX_FD=$( ulimit -H -n ) || +warn "Could not query maximum file descriptor limit" +esac +case $MAX_FD in #( +'' | soft) :;; #( +*) +# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. +# shellcheck disable=SC2039,SC3045 +ulimit -n "$MAX_FD" || +warn "Could not set maximum file descriptor limit to $MAX_FD" +esac fi # Collect all arguments for the java command, stacking in reverse order: @@ -165,55 +169,55 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done +APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) +CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + +JAVACMD=$( cygpath --unix "$JAVACMD" ) + +# Now convert the arguments - kludge to limit ourselves to /bin/sh +for arg do +if +case $arg in #( +-*) false ;; # don't mess with options #( +/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath +[ -e "$t" ] ;; #( +*) false ;; +esac +then +arg=$( cygpath --path --ignore --mixed "$arg" ) +fi +# Roll the args list around exactly as many times as the number of +# args, so each arg winds up back in the position where it started, but +# possibly modified. +# +# NB: a `for` loop captures its iteration list before it begins, so +# changing the positional parameters here affects neither the number of +# iterations, nor the values presented in `arg`. +shift # remove old arg +set -- "$@" "$arg" # push replacement arg +done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" +"-Dorg.gradle.appname=$APP_BASE_NAME" \ +-classpath "$CLASSPATH" \ +org.gradle.wrapper.GradleWrapperMain \ +"$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then - die "xargs is not available" +die "xargs is not available" fi # Use "xargs" to parse quoted args. @@ -236,10 +240,10 @@ fi # eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' +printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | +xargs -n1 | +sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | +tr '\n' ' ' +)" '"$@"' exec "$JAVACMD" "$@" diff --git a/cloud-agent/client/kotlin/gradlew.bat b/cloud-agent/client/kotlin/gradlew.bat index 93e3f59f13..9d0ce634cb 100644 --- a/cloud-agent/client/kotlin/gradlew.bat +++ b/cloud-agent/client/kotlin/gradlew.bat @@ -1,92 +1,249 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while +APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path +[ -h "$app_path" ] +do +ls=$( ls -ld "$app_path" ) +link=${ls#*' -> '} +case $link in #( +/*) app_path=$link ;; #( +*) app_path=$APP_HOME$link ;; +esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { +echo "$*" +} >&2 + +die () { +echo +echo "$*" +echo +exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( +CYGWIN* ) cygwin=true ;; #( +Darwin* ) darwin=true ;; #( +MSYS* | MINGW* ) msys=true ;; #( +NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then +if [ -x "$JAVA_HOME/jre/sh/java" ] ; then +# IBM's JDK on AIX uses strange locations for the executables +JAVACMD=$JAVA_HOME/jre/sh/java +else +JAVACMD=$JAVA_HOME/bin/java +fi +if [ ! -x "$JAVACMD" ] ; then +die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi +else +JAVACMD=java +if ! command -v java >/dev/null 2>&1 +then +die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then +case $MAX_FD in #( +max*) +# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. +# shellcheck disable=SC2039,SC3045 +MAX_FD=$( ulimit -H -n ) || +warn "Could not query maximum file descriptor limit" +esac +case $MAX_FD in #( +'' | soft) :;; #( +*) +# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. +# shellcheck disable=SC2039,SC3045 +ulimit -n "$MAX_FD" || +warn "Could not set maximum file descriptor limit to $MAX_FD" +esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then +APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) +CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + +JAVACMD=$( cygpath --unix "$JAVACMD" ) + +# Now convert the arguments - kludge to limit ourselves to /bin/sh +for arg do +if +case $arg in #( +-*) false ;; # don't mess with options #( +/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath +[ -e "$t" ] ;; #( +*) false ;; +esac +then +arg=$( cygpath --path --ignore --mixed "$arg" ) +fi +# Roll the args list around exactly as many times as the number of +# args, so each arg winds up back in the position where it started, but +# possibly modified. +# +# NB: a `for` loop captures its iteration list before it begins, so +# changing the positional parameters here affects neither the number of +# iterations, nor the values presented in `arg`. +shift # remove old arg +set -- "$@" "$arg" # push replacement arg +done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ +"-Dorg.gradle.appname=$APP_BASE_NAME" \ +-classpath "$CLASSPATH" \ +org.gradle.wrapper.GradleWrapperMain \ +"$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then +die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( +printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | +xargs -n1 | +sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | +tr '\n' ' ' +)" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/cloud-agent/client/kotlin/settings.gradle b/cloud-agent/client/kotlin/settings.gradle index b5dc286913..4765fb4704 100644 --- a/cloud-agent/client/kotlin/settings.gradle +++ b/cloud-agent/client/kotlin/settings.gradle @@ -1,2 +1 @@ - -rootProject.name = 'cloud-agent-client-kotlin' \ No newline at end of file +rootProject.name = 'cloud-agent-client-kotlin' diff --git a/cloud-agent/client/kotlin/src/main/kotlin/org/hyperledger/identus/client/adapters/StringOrStringArrayAdapter.kt b/cloud-agent/client/kotlin/src/main/kotlin/org/hyperledger/identus/client/adapters/StringOrStringArrayAdapter.kt new file mode 100644 index 0000000000..cebdbbe604 --- /dev/null +++ b/cloud-agent/client/kotlin/src/main/kotlin/org/hyperledger/identus/client/adapters/StringOrStringArrayAdapter.kt @@ -0,0 +1,33 @@ +package org.hyperledger.identus.client.adapters + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonSerializer +import com.google.gson.JsonNull +import com.google.gson.JsonParseException +import com.google.gson.JsonPrimitive +import com.google.gson.JsonSerializationContext +import java.lang.reflect.Type + +class StringOrStringArrayAdapter : JsonSerializer>, JsonDeserializer> { + + // Deserialize logic: String or Array of Strings to List + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): List { + return when { + json.isJsonArray -> context.deserialize(json, typeOfT) + json.isJsonPrimitive -> listOf(json.asString) + json.isJsonNull -> emptyList() + else -> throw JsonParseException("Unexpected type for field") + } + } + + // Serialize logic: List to String or Array of Strings + override fun serialize(src: List?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { + return when { + src.isNullOrEmpty() -> JsonNull.INSTANCE + src.size == 1 -> JsonPrimitive(src[0]) // If only one string, serialize as a single string + else -> context!!.serialize(src) // Otherwise, serialize as a list + } + } +} \ No newline at end of file diff --git a/cloud-agent/client/kotlin/src/main/kotlin/org/hyperledger/identus/client/models/CreateIssueCredentialRecordRequest.kt b/cloud-agent/client/kotlin/src/main/kotlin/org/hyperledger/identus/client/models/CreateIssueCredentialRecordRequest.kt new file mode 100644 index 0000000000..7518b6a52e --- /dev/null +++ b/cloud-agent/client/kotlin/src/main/kotlin/org/hyperledger/identus/client/models/CreateIssueCredentialRecordRequest.kt @@ -0,0 +1,86 @@ +/** + * + * Please note: + * This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit this file manually. + * + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport" +) + +package org.hyperledger.identus.client.models + +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import org.hyperledger.identus.client.adapters.StringOrStringArrayAdapter + +/** + * + * + * @param claims The set of claims that will be included in the issued credential. The JSON object should comply with the schema applicable for this offer (i.e. 'schemaId' or 'credentialDefinitionId'). + * @param issuingDID The issuer Prism DID by which the verifiable credential will be issued. DID can be short for or long form. + * @param validityPeriod The validity period in seconds of the verifiable credential that will be issued. + * @param schemaId + * @param credentialDefinitionId The unique identifier (UUID) of the credential definition that will be used for this offer. It should be the identifier of a credential definition that exists in the issuer agent's database. Note that this parameter only applies when the offer is of type 'AnonCreds'. + * @param credentialFormat The credential format for this offer (defaults to 'JWT') + * @param automaticIssuance Specifies whether or not the credential should be automatically generated and issued when receiving the `CredentialRequest` from the holder. If set to `false`, a manual approval by the issuer via another API call will be required for the VC to be issued. + * @param issuingKid Specified the key ID (kid) of the DID, it will be used to sign credential. User should specify just the partial identifier of the key. The full id of the kid MUST be \"#\" Note the cryto algorithm used with depend type of the key. + * @param connectionId The unique identifier of a DIDComm connection that already exists between the this issuer agent and the holder cloud or edeg agent. It should be the identifier of a connection that exists in the issuer agent's database. This connection will be used to execute the issue credential protocol. Note: connectionId is only required when the offer is from existing connection. connectionId is not required when the offer is from invitation for connectionless issuance. + * @param goalCode A self-attested code the receiver may want to display to the user or use in automatically deciding what to do with the out-of-band message. goalcode is optional and can be provided when the offer is from invitation for connectionless issuance. + * @param goal A self-attested string that the receiver may want to display to the user about the context-specific goal of the out-of-band message. goal is optional and can be provided when the offer is from invitation for connectionless issuance. + */ + + +data class CreateIssueCredentialRecordRequest( + + /* The set of claims that will be included in the issued credential. The JSON object should comply with the schema applicable for this offer (i.e. 'schemaId' or 'credentialDefinitionId'). */ + @SerializedName("claims") + val claims: kotlin.Any?, + + /* The issuer Prism DID by which the verifiable credential will be issued. DID can be short for or long form. */ + @SerializedName("issuingDID") + val issuingDID: kotlin.String, + + /* The validity period in seconds of the verifiable credential that will be issued. */ + @SerializedName("validityPeriod") + val validityPeriod: kotlin.Double? = null, + + @SerializedName("schemaId") + @JsonAdapter(StringOrStringArrayAdapter::class) + val schemaId: kotlin.collections.List? = null, + + /* The unique identifier (UUID) of the credential definition that will be used for this offer. It should be the identifier of a credential definition that exists in the issuer agent's database. Note that this parameter only applies when the offer is of type 'AnonCreds'. */ + @SerializedName("credentialDefinitionId") + val credentialDefinitionId: java.util.UUID? = null, + + /* The credential format for this offer (defaults to 'JWT') */ + @SerializedName("credentialFormat") + val credentialFormat: kotlin.String? = null, + + /* Specifies whether or not the credential should be automatically generated and issued when receiving the `CredentialRequest` from the holder. If set to `false`, a manual approval by the issuer via another API call will be required for the VC to be issued. */ + @SerializedName("automaticIssuance") + val automaticIssuance: kotlin.Boolean? = null, + + /* Specified the key ID (kid) of the DID, it will be used to sign credential. User should specify just the partial identifier of the key. The full id of the kid MUST be \"#\" Note the cryto algorithm used with depend type of the key. */ + @SerializedName("issuingKid") + val issuingKid: kotlin.String? = null, + + /* The unique identifier of a DIDComm connection that already exists between the this issuer agent and the holder cloud or edeg agent. It should be the identifier of a connection that exists in the issuer agent's database. This connection will be used to execute the issue credential protocol. Note: connectionId is only required when the offer is from existing connection. connectionId is not required when the offer is from invitation for connectionless issuance. */ + @SerializedName("connectionId") + val connectionId: java.util.UUID? = null, + + /* A self-attested code the receiver may want to display to the user or use in automatically deciding what to do with the out-of-band message. goalcode is optional and can be provided when the offer is from invitation for connectionless issuance. */ + @SerializedName("goalCode") + val goalCode: kotlin.String? = null, + + /* A self-attested string that the receiver may want to display to the user about the context-specific goal of the out-of-band message. goal is optional and can be provided when the offer is from invitation for connectionless issuance. */ + @SerializedName("goal") + val goal: kotlin.String? = null + +) + diff --git a/cloud-agent/client/kotlin/src/main/kotlin/org/hyperledger/identus/client/models/Service.kt b/cloud-agent/client/kotlin/src/main/kotlin/org/hyperledger/identus/client/models/Service.kt index a331caee07..0ec683b89b 100644 --- a/cloud-agent/client/kotlin/src/main/kotlin/org/hyperledger/identus/client/models/Service.kt +++ b/cloud-agent/client/kotlin/src/main/kotlin/org/hyperledger/identus/client/models/Service.kt @@ -15,35 +15,10 @@ package org.hyperledger.identus.client.models -import com.google.gson.* +import com.google.gson.JsonElement import com.google.gson.annotations.JsonAdapter import com.google.gson.annotations.SerializedName -import java.lang.reflect.Type - -class StringOrStringArrayAdapter : JsonSerializer>, JsonDeserializer> { - - // Deserialize logic: String or Array of Strings to List - override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): List { - return when { - json?.isJsonArray == true -> { - context!!.deserialize(json, typeOfT) - } - json?.isJsonPrimitive == true -> { - listOf(json.asString) - } - else -> throw JsonParseException("Unexpected type for field") - } - } - - // Serialize logic: List to String or Array of Strings - override fun serialize(src: List?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { - return when { - src == null -> JsonNull.INSTANCE - src.size == 1 -> JsonPrimitive(src[0]) // If only one string, serialize as a single string - else -> context!!.serialize(src) // Otherwise, serialize as a list - } - } -} +import org.hyperledger.identus.client.adapters.StringOrStringArrayAdapter data class Service( @@ -58,4 +33,4 @@ data class Service( @SerializedName("serviceEndpoint") val serviceEndpoint: JsonElement? = null, -) + ) diff --git a/cloud-agent/client/typescript/.openapi-generator-ignore b/cloud-agent/client/typescript/.openapi-generator-ignore index af11cd214d..cf11c2202e 100644 --- a/cloud-agent/client/typescript/.openapi-generator-ignore +++ b/cloud-agent/client/typescript/.openapi-generator-ignore @@ -6,3 +6,4 @@ models/CredentialRequest.ts models/Proof2.ts models/Service.ts models/UpdateManagedDIDServiceAction.ts +models/CreateIssueCredentialRecordRequest.ts diff --git a/cloud-agent/client/typescript/models/CreateIssueCredentialRecordRequest.ts b/cloud-agent/client/typescript/models/CreateIssueCredentialRecordRequest.ts new file mode 100644 index 0000000000..14190cd10d --- /dev/null +++ b/cloud-agent/client/typescript/models/CreateIssueCredentialRecordRequest.ts @@ -0,0 +1,135 @@ +/** + * Identus Cloud Agent API Reference + * The Identus Cloud Agent API facilitates the integration and management of self-sovereign identity capabilities within applications. It supports DID (Decentralized Identifiers) management, verifiable credential exchange, and secure messaging based on DIDComm standards. The API is designed to be interoperable with various blockchain and DLT (Distributed Ledger Technology) platforms, ensuring wide compatibility and flexibility. Key features include connection management, credential issuance and verification, and secure, privacy-preserving communication between entities. Additional information and the full list of capabilities can be found in the [Open Enterprise Agent documentation](https://docs.atalaprism.io/docs/category/prism-cloud-agent) + * + * OpenAPI spec version: 1.39.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http'; + +export class CreateIssueCredentialRecordRequest { + /** + * The validity period in seconds of the verifiable credential that will be issued. + */ + 'validityPeriod'?: number; + 'schemaId'?: string | Array; + /** + * The unique identifier (UUID) of the credential definition that will be used for this offer. It should be the identifier of a credential definition that exists in the issuer agent\'s database. Note that this parameter only applies when the offer is of type \'AnonCreds\'. + */ + 'credentialDefinitionId'?: string; + /** + * The credential format for this offer (defaults to \'JWT\') + */ + 'credentialFormat'?: string; + /** + * The set of claims that will be included in the issued credential. The JSON object should comply with the schema applicable for this offer (i.e. \'schemaId\' or \'credentialDefinitionId\'). + */ + 'claims': any | null; + /** + * Specifies whether or not the credential should be automatically generated and issued when receiving the `CredentialRequest` from the holder. If set to `false`, a manual approval by the issuer via another API call will be required for the VC to be issued. + */ + 'automaticIssuance'?: boolean; + /** + * The issuer Prism DID by which the verifiable credential will be issued. DID can be short for or long form. + */ + 'issuingDID': string; + /** + * Specified the key ID (kid) of the DID, it will be used to sign credential. User should specify just the partial identifier of the key. The full id of the kid MUST be \"#\" Note the cryto algorithm used with depend type of the key. + */ + 'issuingKid'?: string; + /** + * The unique identifier of a DIDComm connection that already exists between the this issuer agent and the holder cloud or edeg agent. It should be the identifier of a connection that exists in the issuer agent\'s database. This connection will be used to execute the issue credential protocol. Note: connectionId is only required when the offer is from existing connection. connectionId is not required when the offer is from invitation for connectionless issuance. + */ + 'connectionId'?: string; + /** + * A self-attested code the receiver may want to display to the user or use in automatically deciding what to do with the out-of-band message. goalcode is optional and can be provided when the offer is from invitation for connectionless issuance. + */ + 'goalCode'?: string; + /** + * A self-attested string that the receiver may want to display to the user about the context-specific goal of the out-of-band message. goal is optional and can be provided when the offer is from invitation for connectionless issuance. + */ + 'goal'?: string; + + static readonly discriminator: string | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "validityPeriod", + "baseName": "validityPeriod", + "type": "number", + "format": "double" + }, + { + "name": "schemaId", + "baseName": "schemaId", + "type": "CreateIssueCredentialRecordRequestSchemaId", + "format": "" + }, + { + "name": "credentialDefinitionId", + "baseName": "credentialDefinitionId", + "type": "string", + "format": "uuid" + }, + { + "name": "credentialFormat", + "baseName": "credentialFormat", + "type": "string", + "format": "" + }, + { + "name": "claims", + "baseName": "claims", + "type": "any", + "format": "" + }, + { + "name": "automaticIssuance", + "baseName": "automaticIssuance", + "type": "boolean", + "format": "" + }, + { + "name": "issuingDID", + "baseName": "issuingDID", + "type": "string", + "format": "" + }, + { + "name": "issuingKid", + "baseName": "issuingKid", + "type": "string", + "format": "" + }, + { + "name": "connectionId", + "baseName": "connectionId", + "type": "string", + "format": "uuid" + }, + { + "name": "goalCode", + "baseName": "goalCode", + "type": "string", + "format": "" + }, + { + "name": "goal", + "baseName": "goal", + "type": "string", + "format": "" + } ]; + + static getAttributeTypeMap() { + return CreateIssueCredentialRecordRequest.attributeTypeMap; + } + + public constructor() { + } +} + diff --git a/tests/integration-tests/build.gradle.kts b/tests/integration-tests/build.gradle.kts index 3efb0d40a8..7a653b4398 100644 --- a/tests/integration-tests/build.gradle.kts +++ b/tests/integration-tests/build.gradle.kts @@ -33,7 +33,7 @@ dependencies { testImplementation("io.ktor:ktor-server-netty:2.3.0") testImplementation("io.ktor:ktor-client-apache:2.3.0") // RestAPI client - testImplementation("org.hyperledger.identus:cloud-agent-client-kotlin:1.39.1-e8ad592") + testImplementation("org.hyperledger.identus:cloud-agent-client-kotlin:1.39.1-bbcedb1") // Test helpers library testImplementation("io.iohk.atala:atala-automation:0.4.0") // Hoplite for configuration diff --git a/tests/integration-tests/src/test/kotlin/steps/credentials/JwtCredentialSteps.kt b/tests/integration-tests/src/test/kotlin/steps/credentials/JwtCredentialSteps.kt index b5026a7163..a25abc6ade 100644 --- a/tests/integration-tests/src/test/kotlin/steps/credentials/JwtCredentialSteps.kt +++ b/tests/integration-tests/src/test/kotlin/steps/credentials/JwtCredentialSteps.kt @@ -35,7 +35,7 @@ class JwtCredentialSteps { } val credentialOfferRequest = CreateIssueCredentialRecordRequest( - schemaId = schemaId, + schemaId = schemaId?.let { listOf(it) }, claims = claims, issuingDID = did, connectionId = issuer.recall("connection-with-${holder.name}").connectionId, diff --git a/tests/performance-tests/agent-performance-tests-k6/.env b/tests/performance-tests/agent-performance-tests-k6/.env index 5d67c0931e..24d8296cbe 100644 --- a/tests/performance-tests/agent-performance-tests-k6/.env +++ b/tests/performance-tests/agent-performance-tests-k6/.env @@ -1,3 +1,3 @@ -AGENT_VERSION=1.39.0-SNAPSHOT -PRISM_NODE_VERSION=2.3.0 +AGENT_VERSION=1.39.1-SNAPSHOT +PRISM_NODE_VERSION=2.5.0 VAULT_DEV_ROOT_TOKEN_ID=root diff --git a/tests/performance-tests/agent-performance-tests-k6/package.json b/tests/performance-tests/agent-performance-tests-k6/package.json index 3286629382..65d5b5f020 100644 --- a/tests/performance-tests/agent-performance-tests-k6/package.json +++ b/tests/performance-tests/agent-performance-tests-k6/package.json @@ -26,7 +26,7 @@ "webpack": "webpack" }, "dependencies": { - "@hyperledger/identus-cloud-agent-client-ts": "^1.39.0-e077cdd", + "@hyperledger/identus-cloud-agent-client-ts": "^1.39.1-bbcedb1", "uuid": "^9.0.0" } } diff --git a/tests/performance-tests/agent-performance-tests-k6/yarn.lock b/tests/performance-tests/agent-performance-tests-k6/yarn.lock index e743505b63..736d3f9bc0 100644 --- a/tests/performance-tests/agent-performance-tests-k6/yarn.lock +++ b/tests/performance-tests/agent-performance-tests-k6/yarn.lock @@ -993,10 +993,10 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== -"@hyperledger/identus-cloud-agent-client-ts@^1.39.0-e077cdd": - version "1.39.0-e077cdd" - resolved "https://npm.pkg.github.com/download/@hyperledger/identus-cloud-agent-client-ts/1.39.0-e077cdd/06803b9bd2fa7d63805f83df22250882e84c94dd#06803b9bd2fa7d63805f83df22250882e84c94dd" - integrity sha512-3FSz2WlrykyF5LqnrI+wcbrY33i8CeyBNQmEYZ9fp84JL3qhWvnF92dBIS9qpgAQY/qIg5vHvL7RIRNrAxQfOw== +"@hyperledger/identus-cloud-agent-client-ts@^1.39.1-bbcedb1": + version "1.39.1-bbcedb1" + resolved "https://npm.pkg.github.com/download/@hyperledger/identus-cloud-agent-client-ts/1.39.1-bbcedb1/88aaeabfc4d2d8949e21014c2a5c9297ed055d42#88aaeabfc4d2d8949e21014c2a5c9297ed055d42" + integrity sha512-FjYV4HN5H/LD/v6dOw/vMaqU3f8v1IKzEMtUfj9qmLHVmr1FwxkZWSj6wE27I+sY/0sGAPCF/rPbvO27UWdtYQ== dependencies: es6-promise "^4.2.4" url-parse "^1.4.3" From 19ab426a191eec575ffebe6a2417f3fce538969c Mon Sep 17 00:00:00 2001 From: bvoiturier Date: Wed, 9 Oct 2024 09:49:36 +0200 Subject: [PATCH 04/10] feat: ATL-6983 ZIO Stream Kafka PoC in background jobs (#1339) Signed-off-by: Benjamin Voiturier Signed-off-by: mineme0110 Signed-off-by: Hyperledger Bot Co-authored-by: mineme0110 Co-authored-by: Hyperledger Bot --- build.sbt | 13 +- .../src/main/resources/application.conf | 67 +++-- .../identus/agent/server/CloudAgentApp.scala | 94 +----- .../identus/agent/server/MainApp.scala | 15 +- .../agent/server/config/AppConfig.scala | 18 +- .../http/CustomServerInterceptors.scala | 93 +++--- .../server/http/ZHttp4sBlazeServer.scala | 7 +- .../server/jobs/BackgroundJobsHelper.scala | 22 +- .../server/jobs/ConnectBackgroundJobs.scala | 84 +++--- .../jobs/DIDStateSyncBackgroundJobs.scala | 51 +++- .../server/jobs/IssueBackgroundJobs.scala | 100 +++---- .../server/jobs/PresentBackgroundJobs.scala | 80 +++--- .../agent/server/jobs/StatusListJobs.scala | 268 +++++++++++------- .../controller/IssueControllerTestTools.scala | 6 +- .../OIDCCredentialIssuerServiceSpec.scala | 5 +- .../CredentialDefinitionTestTools.scala | 6 +- .../schema/CredentialSchemaTestTools.scala | 6 +- .../SystemControllerTestTools.scala | 6 +- .../VcVerificationControllerTestTools.scala | 6 +- .../sql/agent/V15__add_did_index_table.sql | 19 ++ .../service/ManagedDIDServiceImpl.scala | 19 +- ...dDIDServiceWithEventNotificationImpl.scala | 5 +- .../service/handler/DIDCreateHandler.scala | 7 +- .../sql/JdbcDIDNonSecretStorage.scala | 53 +++- .../storage/DIDNonSecretStorage.scala | 2 + .../storage/MockDIDNonSecretStorage.scala | 4 + .../core/model/WalletIdAndRecordId.scala | 19 ++ .../core/service/ConnectionServiceImpl.scala | 19 +- .../service/ConnectionServiceImplSpec.scala | 12 +- .../ConnectionServiceNotifierSpec.scala | 8 +- .../messaging/MessagingServiceTest.scala | 49 ++++ .../kafka/InMemoryMessagingServiceSpec.scala | 66 +++++ .../shared/docker-compose-with-kafka.yml | 256 +++++++++++++++++ infrastructure/shared/nginx/nginx.conf | 42 +++ .../identus/pollux/core/model/DidCommID.scala | 4 +- .../CredentialStatusListRepository.scala | 47 ++- .../core/service/CredentialServiceImpl.scala | 72 +++-- .../service/CredentialStatusListService.scala | 6 +- .../CredentialStatusListServiceImpl.scala | 9 +- .../core/service/PresentationService.scala | 2 +- .../service/PresentationServiceImpl.scala | 49 +++- .../service/PresentationServiceNotifier.scala | 3 +- ...edentialStatusListRepositoryInMemory.scala | 182 ++++++------ .../service/CredentialServiceSpecHelper.scala | 4 +- .../service/MockPresentationService.scala | 5 +- .../PresentationServiceSpecHelper.scala | 6 +- .../JdbcCredentialStatusListRepository.scala | 213 +++++++------- .../JdbcPresentationRepository.scala | 1 - .../shared/messaging/MessagingService.scala | 106 +++++++ .../messaging/MessagingServiceConfig.scala | 58 ++++ .../identus/shared/messaging/Serde.scala | 55 ++++ .../messaging/WalletIdAndRecordId.scala | 20 ++ .../kafka/InMemoryMessagingService.scala | 146 ++++++++++ .../kafka/ZKafkaMessagingServiceImpl.scala | 136 +++++++++ .../src/test/resources/containers/agent.yml | 94 ++++++ 55 files changed, 2031 insertions(+), 714 deletions(-) create mode 100644 cloud-agent/service/wallet-api/src/main/resources/sql/agent/V15__add_did_index_table.sql create mode 100644 connect/core/src/main/scala/org/hyperledger/identus/connect/core/model/WalletIdAndRecordId.scala create mode 100644 event-notification/src/test/scala/org/hyperledger/identus/messaging/MessagingServiceTest.scala create mode 100644 event-notification/src/test/scala/org/hyperledger/identus/messaging/kafka/InMemoryMessagingServiceSpec.scala create mode 100644 infrastructure/shared/docker-compose-with-kafka.yml create mode 100644 infrastructure/shared/nginx/nginx.conf create mode 100644 shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/MessagingService.scala create mode 100644 shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/MessagingServiceConfig.scala create mode 100644 shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/Serde.scala create mode 100644 shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/WalletIdAndRecordId.scala create mode 100644 shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/kafka/InMemoryMessagingService.scala create mode 100644 shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/kafka/ZKafkaMessagingServiceImpl.scala diff --git a/build.sbt b/build.sbt index ca50625deb..0df784ff60 100644 --- a/build.sbt +++ b/build.sbt @@ -36,7 +36,8 @@ inThisBuild( // scalacOptions += "-Yexplicit-nulls", // scalacOptions += "-Ysafe-init", // scalacOptions += "-Werror", // <=> "-Xfatal-warnings" - scalacOptions += "-Dquill.macro.log=false", // disable quill macro logs // TODO https://github.com/zio/zio-protoquill/issues/470 + scalacOptions += "-Dquill.macro.log=false", // disable quill macro logs // TODO https://github.com/zio/zio-protoquill/issues/470, + scalacOptions ++= Seq("-Xmax-inlines", "50") // manually increase max-inlines above 32 (https://github.com/circe/circe/issues/2162) ) ) @@ -53,6 +54,7 @@ lazy val V = new { val zioCatsInterop = "3.3.0" // TODO "23.1.0.2" // https://mvnrepository.com/artifact/dev.zio/zio-interop-cats val zioMetricsConnector = "2.3.1" val zioMock = "1.0.0-RC12" + val zioKafka = "2.7.5" val mockito = "3.2.18.0" val monocle = "3.2.0" @@ -102,7 +104,11 @@ lazy val D = new { val zioLog: ModuleID = "dev.zio" %% "zio-logging" % V.zioLogging val zioSLF4J: ModuleID = "dev.zio" %% "zio-logging-slf4j" % V.zioLogging val zioJson: ModuleID = "dev.zio" %% "zio-json" % V.zioJson + val zioConcurrent: ModuleID = "dev.zio" %% "zio-concurrent" % V.zio val zioHttp: ModuleID = "dev.zio" %% "zio-http" % V.zioHttp + val zioKafka: ModuleID = "dev.zio" %% "zio-kafka" % V.zioKafka excludeAll ( + ExclusionRule("dev.zio", "zio_3"), ExclusionRule("dev.zio", "zio-streams_3") + ) val zioCatsInterop: ModuleID = "dev.zio" %% "zio-interop-cats" % V.zioCatsInterop val zioMetricsConnectorMicrometer: ModuleID = "dev.zio" %% "zio-metrics-connectors-micrometer" % V.zioMetricsConnector val tapirPrometheusMetrics: ModuleID = "com.softwaremill.sttp.tapir" %% "tapir-prometheus-metrics" % V.tapir @@ -185,7 +191,9 @@ lazy val D_Shared = new { D.typesafeConfig, D.scalaPbGrpc, D.zio, + D.zioConcurrent, D.zioHttp, + D.zioKafka, D.scalaUri, D.zioPrelude, // FIXME: split shared DB stuff as subproject? @@ -341,12 +349,11 @@ lazy val D_Pollux_VC_JWT = new { lazy val D_EventNotification = new { val zio = "dev.zio" %% "zio" % V.zio - val zioConcurrent = "dev.zio" %% "zio-concurrent" % V.zio val zioTest = "dev.zio" %% "zio-test" % V.zio % Test val zioTestSbt = "dev.zio" %% "zio-test-sbt" % V.zio % Test val zioTestMagnolia = "dev.zio" %% "zio-test-magnolia" % V.zio % Test - val zioDependencies: Seq[ModuleID] = Seq(zio, zioConcurrent, zioTest, zioTestSbt, zioTestMagnolia) + val zioDependencies: Seq[ModuleID] = Seq(zio, zioTest, zioTestSbt, zioTestMagnolia) val baseDependencies: Seq[ModuleID] = zioDependencies } diff --git a/cloud-agent/service/server/src/main/resources/application.conf b/cloud-agent/service/server/src/main/resources/application.conf index 13f2a0b4bb..7b5bebb01a 100644 --- a/cloud-agent/service/server/src/main/resources/application.conf +++ b/cloud-agent/service/server/src/main/resources/application.conf @@ -34,22 +34,10 @@ pollux { publicEndpointUrl = "http://localhost:"${agent.httpEndpoint.http.port} publicEndpointUrl = ${?POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL} } - issueBgJobRecordsLimit = 25 - issueBgJobRecordsLimit = ${?ISSUE_BG_JOB_RECORDS_LIMIT} - issueBgJobRecurrenceDelay = 2 seconds - issueBgJobRecurrenceDelay = ${?ISSUE_BG_JOB_RECURRENCE_DELAY} - issueBgJobProcessingParallelism = 5 - issueBgJobProcessingParallelism = ${?ISSUE_BG_JOB_PROCESSING_PARALLELISM} - presentationBgJobRecordsLimit = 25 - presentationBgJobRecordsLimit = ${?PRESENTATION_BG_JOB_RECORDS_LIMIT} - presentationBgJobRecurrenceDelay = 2 seconds - presentationBgJobRecurrenceDelay = ${?PRESENTATION_BG_JOB_RECURRENCE_DELAY} - presentationBgJobProcessingParallelism = 5 - presentationBgJobProcessingParallelism = ${?PRESENTATION_BG_JOB_PROCESSING_PARALLELISM} - syncRevocationStatusesBgJobRecurrenceDelay = 2 seconds - syncRevocationStatusesBgJobRecurrenceDelay = ${?SYNC_REVOCATION_STATUSES_BG_JOB_RECURRENCE_DELAY} - syncRevocationStatusesBgJobProcessingParallelism = 5 - syncRevocationStatusesBgJobProcessingParallelism = ${?SYNC_REVOCATION_STATUSES_BG_JOB_PROCESSING_PARALLELISM} + statusListSyncTriggerRecurrenceDelay = 30 seconds + statusListSyncTriggerRecurrenceDelay = ${?STATUS_LIST_SYNC_TRIGGER_RECURRENCE_DELAY} + didStateSyncTriggerRecurrenceDelay = 30 seconds + didStateSyncTriggerRecurrenceDelay = ${?DID_STATE_SYNC_TRIGGER_RECURRENCE_DELAY} credential.sdJwt.expiry = 30 days credential.sdJwt.expiry = ${?CREDENTIAL_SD_JWT_EXPIRY} presentationInvitationExpiry = 300 seconds @@ -81,8 +69,6 @@ connect { connectBgJobRecordsLimit = ${?CONNECT_BG_JOB_RECORDS_LIMIT} connectBgJobRecurrenceDelay = 2 seconds connectBgJobRecurrenceDelay = ${?CONNECT_BG_JOB_RECURRENCE_DELAY} - connectBgJobProcessingParallelism = 5 - connectBgJobProcessingParallelism = ${?CONNECT_BG_JOB_PROCESSING_PARALLELISM} connectInvitationExpiry = 300 seconds connectInvitationExpiry = ${?CONNECT_INVITATION_EXPIRY} } @@ -262,4 +248,49 @@ agent { authApiKey = "default" authApiKey = ${?DEFAULT_WALLET_AUTH_API_KEY} } + messagingService { + connectFlow { + consumerCount = 5 + retryStrategy { + maxRetries = 4 + initialDelay = 5.seconds + maxDelay = 40.seconds + } + } + issueFlow { + consumerCount = 5 + retryStrategy { + maxRetries = 4 + initialDelay = 5.seconds + maxDelay = 40.seconds + } + } + presentFlow { + consumerCount = 5 + retryStrategy { + maxRetries = 4 + initialDelay = 5.seconds + maxDelay = 40.seconds + } + } + didStateSync { + consumerCount = 5 + } + statusListSync { + consumerCount = 5 + } + inMemoryQueueCapacity = 1000 + kafkaEnabled = false + kafkaEnabled = ${?DEFAULT_KAFKA_ENABLED} + kafka { + bootstrapServers = "kafka:9092" + consumers { + autoCreateTopics = false, + maxPollRecords = 500 + maxPollInterval = 5.minutes + pollTimeout = 50.millis + rebalanceSafeCommits = true + } + } + } } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/CloudAgentApp.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/CloudAgentApp.scala index d3af09cf2b..d010d4c5f6 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/CloudAgentApp.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/CloudAgentApp.scala @@ -5,12 +5,9 @@ import org.hyperledger.identus.agent.server.config.AppConfig import org.hyperledger.identus.agent.server.http.{ZHttp4sBlazeServer, ZHttpEndpoints} import org.hyperledger.identus.agent.server.jobs.* import org.hyperledger.identus.agent.walletapi.model.{Entity, Wallet, WalletSeed} -import org.hyperledger.identus.agent.walletapi.service.{EntityService, ManagedDIDService, WalletManagementService} -import org.hyperledger.identus.agent.walletapi.storage.DIDNonSecretStorage +import org.hyperledger.identus.agent.walletapi.service.{EntityService, WalletManagementService} import org.hyperledger.identus.castor.controller.{DIDRegistrarServerEndpoints, DIDServerEndpoints} -import org.hyperledger.identus.castor.core.service.DIDService import org.hyperledger.identus.connect.controller.ConnectionServerEndpoints -import org.hyperledger.identus.connect.core.service.ConnectionService import org.hyperledger.identus.credentialstatus.controller.CredentialStatusServiceEndpoints import org.hyperledger.identus.event.controller.EventServerEndpoints import org.hyperledger.identus.event.notification.EventNotificationConfig @@ -18,108 +15,35 @@ import org.hyperledger.identus.iam.authentication.apikey.ApiKeyAuthenticator import org.hyperledger.identus.iam.entity.http.EntityServerEndpoints import org.hyperledger.identus.iam.wallet.http.WalletManagementServerEndpoints import org.hyperledger.identus.issue.controller.IssueServerEndpoints -import org.hyperledger.identus.mercury.{DidOps, HttpClient} import org.hyperledger.identus.oid4vci.CredentialIssuerServerEndpoints -import org.hyperledger.identus.pollux.core.service.{CredentialService, PresentationService} import org.hyperledger.identus.pollux.credentialdefinition.CredentialDefinitionRegistryServerEndpoints import org.hyperledger.identus.pollux.credentialschema.{ SchemaRegistryServerEndpoints, VerificationPolicyServerEndpoints } import org.hyperledger.identus.pollux.prex.PresentationExchangeServerEndpoints -import org.hyperledger.identus.pollux.vc.jwt.DidResolver as JwtDidResolver import org.hyperledger.identus.presentproof.controller.PresentProofServerEndpoints -import org.hyperledger.identus.resolvers.DIDResolver -import org.hyperledger.identus.shared.http.UriResolver -import org.hyperledger.identus.shared.models.{HexString, WalletAccessContext, WalletAdministrationContext, WalletId} -import org.hyperledger.identus.shared.utils.DurationOps.toMetricsSeconds +import org.hyperledger.identus.shared.models.* import org.hyperledger.identus.system.controller.SystemServerEndpoints import org.hyperledger.identus.verification.controller.VcVerificationServerEndpoints import zio.* -import zio.metrics.* - object CloudAgentApp { def run = for { _ <- AgentInitialization.run - _ <- issueCredentialDidCommExchangesJob.debug.fork - _ <- presentProofExchangeJob.debug.fork - _ <- connectDidCommExchangesJob.debug.fork - _ <- syncDIDPublicationStateFromDltJob.debug.fork - _ <- syncRevocationStatusListsJob.debug.fork + _ <- ConnectBackgroundJobs.connectFlowsHandler + _ <- IssueBackgroundJobs.issueFlowsHandler + _ <- PresentBackgroundJobs.presentFlowsHandler + _ <- DIDStateSyncBackgroundJobs.didStateSyncTrigger + _ <- DIDStateSyncBackgroundJobs.didStateSyncHandler + _ <- StatusListJobs.statusListsSyncTrigger + _ <- StatusListJobs.statusListSyncHandler _ <- AgentHttpServer.run.tapDefect(e => ZIO.logErrorCause("Agent HTTP Server failure", e)).fork fiber <- DidCommHttpServer.run.tapDefect(e => ZIO.logErrorCause("DIDComm HTTP Server failure", e)).fork _ <- WebhookPublisher.layer.build.map(_.get[WebhookPublisher]).flatMap(_.run.fork) _ <- fiber.join *> ZIO.log(s"Server End") _ <- ZIO.never } yield () - - private val issueCredentialDidCommExchangesJob: RIO[ - AppConfig & DidOps & DIDResolver & JwtDidResolver & HttpClient & CredentialService & DIDNonSecretStorage & - DIDService & ManagedDIDService & PresentationService & WalletManagementService, - Unit - ] = - for { - config <- ZIO.service[AppConfig] - _ <- (IssueBackgroundJobs.issueCredentialDidCommExchanges @@ Metric - .gauge("issuance_flow_did_com_exchange_job_ms_gauge") - .trackDurationWith(_.toMetricsSeconds)) - .repeat(Schedule.spaced(config.pollux.issueBgJobRecurrenceDelay)) - .unit - } yield () - - private val presentProofExchangeJob: RIO[ - AppConfig & DidOps & UriResolver & DIDResolver & JwtDidResolver & HttpClient & PresentationService & - CredentialService & DIDNonSecretStorage & DIDService & ManagedDIDService, - Unit - ] = - for { - config <- ZIO.service[AppConfig] - _ <- (PresentBackgroundJobs.presentProofExchanges @@ Metric - .gauge("present_proof_flow_did_com_exchange_job_ms_gauge") - .trackDurationWith(_.toMetricsSeconds)) - .repeat(Schedule.spaced(config.pollux.presentationBgJobRecurrenceDelay)) - .unit - } yield () - - private val connectDidCommExchangesJob: RIO[ - AppConfig & DidOps & DIDResolver & HttpClient & ConnectionService & ManagedDIDService & DIDNonSecretStorage & - WalletManagementService, - Unit - ] = - for { - config <- ZIO.service[AppConfig] - _ <- (ConnectBackgroundJobs.didCommExchanges @@ Metric - .gauge("connection_flow_did_com_exchange_job_ms_gauge") - .trackDurationWith(_.toMetricsSeconds)) - .repeat(Schedule.spaced(config.connect.connectBgJobRecurrenceDelay)) - .unit - } yield () - - private val syncRevocationStatusListsJob = { - for { - config <- ZIO.service[AppConfig] - _ <- (StatusListJobs.syncRevocationStatuses @@ Metric - .gauge("revocation_status_list_sync_job_ms_gauge") - .trackDurationWith(_.toMetricsSeconds)) - .repeat(Schedule.spaced(config.pollux.syncRevocationStatusesBgJobRecurrenceDelay)) - } yield () - } - - private val syncDIDPublicationStateFromDltJob: URIO[ManagedDIDService & WalletManagementService, Unit] = - ZIO - .serviceWithZIO[WalletManagementService](_.listWallets().map(_._1)) - .flatMap { wallets => - ZIO.foreach(wallets) { wallet => - DIDStateSyncBackgroundJobs.syncDIDPublicationStateFromDlt - .provideSomeLayer(ZLayer.succeed(WalletAccessContext(wallet.id))) - } - } - .catchAll(e => ZIO.logError(s"error while syncing DID publication state: $e")) - .repeat(Schedule.spaced(10.seconds)) - .unit - .provideSomeLayer(ZLayer.succeed(WalletAdministrationContext.Admin())) - } object AgentHttpServer { diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/MainApp.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/MainApp.scala index 9f163a0a11..922593389a 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/MainApp.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/MainApp.scala @@ -7,7 +7,6 @@ import org.hyperledger.identus.agent.server.http.ZioHttpClient import org.hyperledger.identus.agent.server.sql.Migrations as AgentMigrations import org.hyperledger.identus.agent.walletapi.service.{ EntityServiceImpl, - ManagedDIDService, ManagedDIDServiceWithEventNotificationImpl, WalletManagementServiceImpl } @@ -16,7 +15,6 @@ import org.hyperledger.identus.agent.walletapi.sql.{ JdbcEntityRepository, JdbcWalletNonSecretStorage } -import org.hyperledger.identus.agent.walletapi.storage.GenericSecretStorage import org.hyperledger.identus.castor.controller.{DIDControllerImpl, DIDRegistrarControllerImpl} import org.hyperledger.identus.castor.core.model.did.{ Service as DidDocumentService, @@ -36,7 +34,7 @@ import org.hyperledger.identus.iam.authentication.{DefaultAuthenticator, Oid4vci import org.hyperledger.identus.iam.authentication.apikey.JdbcAuthenticationRepository import org.hyperledger.identus.iam.authorization.core.EntityPermissionManagementService import org.hyperledger.identus.iam.authorization.DefaultPermissionManagementService -import org.hyperledger.identus.iam.entity.http.controller.{EntityController, EntityControllerImpl} +import org.hyperledger.identus.iam.entity.http.controller.EntityControllerImpl import org.hyperledger.identus.iam.wallet.http.controller.WalletManagementControllerImpl import org.hyperledger.identus.issue.controller.IssueControllerImpl import org.hyperledger.identus.mercury.* @@ -47,7 +45,6 @@ import org.hyperledger.identus.pollux.core.service.* import org.hyperledger.identus.pollux.core.service.verification.VcVerificationServiceImpl import org.hyperledger.identus.pollux.credentialdefinition.controller.CredentialDefinitionControllerImpl import org.hyperledger.identus.pollux.credentialschema.controller.{ - CredentialSchemaController, CredentialSchemaControllerImpl, VerificationPolicyControllerImpl } @@ -66,6 +63,9 @@ import org.hyperledger.identus.pollux.sql.repository.{ } import org.hyperledger.identus.presentproof.controller.PresentProofControllerImpl import org.hyperledger.identus.resolvers.DIDResolver +import org.hyperledger.identus.shared.messaging +import org.hyperledger.identus.shared.messaging.WalletIdAndRecordId +import org.hyperledger.identus.shared.models.WalletId import org.hyperledger.identus.system.controller.SystemControllerImpl import org.hyperledger.identus.verification.controller.VcVerificationControllerImpl import zio.* @@ -77,6 +77,7 @@ import zio.metrics.connectors.micrometer.MicrometerConfig import zio.metrics.jvm.DefaultJvmMetrics import java.security.Security +import java.util.UUID object MainApp extends ZIOAppDefault { @@ -167,7 +168,6 @@ object MainApp extends ZIOAppDefault { ) _ <- preMigrations _ <- migrations - app <- CloudAgentApp.run .provide( DidCommX.liveLayer, @@ -252,6 +252,11 @@ object MainApp extends ZIOAppDefault { // HTTP client SystemModule.zioHttpClientLayer, Scope.default, + // Messaging Service + ZLayer.fromZIO(ZIO.service[AppConfig].map(_.agent.messagingService)), + messaging.MessagingService.serviceLayer, + messaging.MessagingService.producerLayer[UUID, WalletIdAndRecordId], + messaging.MessagingService.producerLayer[WalletId, WalletId] ) } yield app diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/config/AppConfig.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/config/AppConfig.scala index 0f75561812..364ff510bc 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/config/AppConfig.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/config/AppConfig.scala @@ -4,7 +4,7 @@ import org.hyperledger.identus.castor.core.model.did.VerificationRelationship import org.hyperledger.identus.iam.authentication.AuthenticationConfig import org.hyperledger.identus.pollux.vc.jwt.* import org.hyperledger.identus.shared.db.DbConfig -import zio.config.* +import org.hyperledger.identus.shared.messaging.MessagingServiceConfig import zio.config.magnolia.* import zio.Config @@ -70,22 +70,13 @@ final case class PolluxConfig( database: DatabaseConfig, credentialSdJwtExpirationTime: Duration, statusListRegistry: StatusListRegistryConfig, - issueBgJobRecordsLimit: Int, - issueBgJobRecurrenceDelay: Duration, - issueBgJobProcessingParallelism: Int, - presentationBgJobRecordsLimit: Int, - presentationBgJobRecurrenceDelay: Duration, - presentationBgJobProcessingParallelism: Int, - syncRevocationStatusesBgJobRecurrenceDelay: Duration, - syncRevocationStatusesBgJobProcessingParallelism: Int, + statusListSyncTriggerRecurrenceDelay: Duration, + didStateSyncTriggerRecurrenceDelay: Duration, presentationInvitationExpiry: Duration, issuanceInvitationExpiry: Duration, ) final case class ConnectConfig( database: DatabaseConfig, - connectBgJobRecordsLimit: Int, - connectBgJobRecurrenceDelay: Duration, - connectBgJobProcessingParallelism: Int, connectInvitationExpiry: Duration, ) @@ -173,7 +164,8 @@ final case class AgentConfig( verification: VerificationConfig, secretStorage: SecretStorageConfig, webhookPublisher: WebhookPublisherConfig, - defaultWallet: DefaultWalletConfig + defaultWallet: DefaultWalletConfig, + messagingService: MessagingServiceConfig ) { def validate: Either[String, Unit] = for { diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/http/CustomServerInterceptors.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/http/CustomServerInterceptors.scala index 0d73369e82..44ffa1cea8 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/http/CustomServerInterceptors.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/http/CustomServerInterceptors.scala @@ -1,9 +1,11 @@ package org.hyperledger.identus.agent.server.http +import org.http4s.{MediaType, Request, Response, Status} +import org.http4s.headers.`Content-Type` +import org.http4s.server.ServiceErrorHandler import org.hyperledger.identus.api.http.ErrorResponse import org.hyperledger.identus.shared.models.{Failure, StatusCode, UnmanagedFailureException} import org.log4s.* -import sttp.tapir.* import sttp.tapir.json.zio.jsonBody import sttp.tapir.server.interceptor.* import sttp.tapir.server.interceptor.decodefailure.{DecodeFailureHandler, DefaultDecodeFailureHandler} @@ -11,6 +13,7 @@ import sttp.tapir.server.interceptor.decodefailure.DefaultDecodeFailureHandler.F import sttp.tapir.server.interceptor.exception.ExceptionHandler import sttp.tapir.server.interceptor.reject.RejectHandler import sttp.tapir.server.model.ValuedEndpointOutput +import zio.{Task, ZIO} import scala.language.implicitConversions @@ -19,7 +22,7 @@ object CustomServerInterceptors { private val logger: Logger = getLogger private val endpointOutput = jsonBody[ErrorResponse] - private def defectHandler(response: ErrorResponse, maybeCause: Option[Throwable] = None) = { + private def tapirDefectHandler(response: ErrorResponse, maybeCause: Option[Throwable] = None) = { val statusCode = sttp.model.StatusCode(response.status) // Log defect as 'error' when status code matches a server error (5xx). Log other defects as 'debug'. (statusCode, maybeCause) match @@ -27,39 +30,43 @@ object CustomServerInterceptors { case (sc, None) if sc.isServerError => logger.error(endpointOutput.codec.encode(response)) case (_, Some(cause)) => logger.debug(cause)(endpointOutput.codec.encode(response)) case (_, None) => logger.debug(endpointOutput.codec.encode(response)) - Some(ValuedEndpointOutput(endpointOutput, response).prepend(sttp.tapir.statusCode, statusCode)) + ValuedEndpointOutput(endpointOutput, response).prepend(sttp.tapir.statusCode, statusCode) } - def exceptionHandler[F[_]]: ExceptionHandler[F] = ExceptionHandler.pure[F](ctx => + def tapirExceptionHandler[F[_]]: ExceptionHandler[F] = ExceptionHandler.pure[F](ctx => ctx.e match - case UnmanagedFailureException(failure: Failure) => defectHandler(failure) + case UnmanagedFailureException(failure: Failure) => Some(tapirDefectHandler(failure)) case e => - defectHandler( - ErrorResponse( - StatusCode.InternalServerError.code, - s"error:InternalServerError", - "Internal Server Error", - Some( - s"An unexpected error occurred when processing the request: " + - s"path=['${ctx.request.showShort}']" - ) - ), - Some(ctx.e) + Some( + tapirDefectHandler( + ErrorResponse( + StatusCode.InternalServerError.code, + s"error:InternalServerError", + "Internal Server Error", + Some( + s"An unexpected error occurred when processing the request: " + + s"path=['${ctx.request.showShort}']" + ) + ), + Some(ctx.e) + ) ) ) - def rejectHandler[F[_]]: RejectHandler[F] = RejectHandler.pure[F](resultFailure => - defectHandler( - ErrorResponse( - StatusCode.NotFound.code, - s"error:ResourcePathNotFound", - "Resource Path Not Found", - Some(s"The requested resource path doesn't exist.") + def tapirRejectHandler[F[_]]: RejectHandler[F] = RejectHandler.pure[F](resultFailure => + Some( + tapirDefectHandler( + ErrorResponse( + StatusCode.NotFound.code, + s"error:ResourcePathNotFound", + "Resource Path Not Found", + Some(s"The requested resource path doesn't exist.") + ) ) ) ) - def decodeFailureHandler: DecodeFailureHandler = (ctx: DecodeFailureContext) => { + def tapirDecodeFailureHandler: DecodeFailureHandler = (ctx: DecodeFailureContext) => { /** As per the Tapir Decode Failures documentation: * @@ -79,17 +86,39 @@ object CustomServerInterceptors { DefaultDecodeFailureHandler.respond(ctx) match case Some((sc, _)) => val details = FailureMessages.failureMessage(ctx) - defectHandler( - ErrorResponse( - sc.code, - s"error:RequestBodyDecodingFailure", - "Request Body Decoding Failure", - Some( - s"An error occurred when decoding the request body: " + - s"path=['${ctx.request.showShort}'], details=[$details]" + Some( + tapirDefectHandler( + ErrorResponse( + sc.code, + s"error:RequestBodyDecodingFailure", + "Request Body Decoding Failure", + Some( + s"An error occurred when decoding the request body: " + + s"path=['${ctx.request.showShort}'], details=[$details]" + ) ) ) ) case None => None } + + def http4sServiceErrorHandler: ServiceErrorHandler[Task] = (req: Request[Task]) => { case t: Throwable => + val res = tapirDefectHandler( + ErrorResponse( + StatusCode.InternalServerError.code, + s"error:InternalServerError", + "Internal Server Error", + Some( + s"An unexpected error occurred when servicing the request: " + + s"path=['${req.method.name} ${req.uri.copy(scheme = None, authority = None, fragment = None).toString}']" + ) + ), + Some(t) + ) + ZIO.succeed( + Response(Status.InternalServerError) + .withEntity(endpointOutput.codec.encode(res.value._2)) + .withContentType(`Content-Type`(MediaType.application.json)) + ) + } } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/http/ZHttp4sBlazeServer.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/http/ZHttp4sBlazeServer.scala index 05d56eb62b..1293185891 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/http/ZHttp4sBlazeServer.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/http/ZHttp4sBlazeServer.scala @@ -93,9 +93,9 @@ class ZHttp4sBlazeServer(micrometerRegistry: PrometheusMeterRegistry, metricsNam options <- ZIO.attempt { Http4sServerOptions .customiseInterceptors[Task] - .exceptionHandler(CustomServerInterceptors.exceptionHandler) - .rejectHandler(CustomServerInterceptors.rejectHandler) - .decodeFailureHandler(CustomServerInterceptors.decodeFailureHandler) + .exceptionHandler(CustomServerInterceptors.tapirExceptionHandler) + .rejectHandler(CustomServerInterceptors.tapirRejectHandler) + .decodeFailureHandler(CustomServerInterceptors.tapirDecodeFailureHandler) .serverLog(None) .metricsInterceptor( srv.metricsInterceptor( @@ -123,6 +123,7 @@ class ZHttp4sBlazeServer(micrometerRegistry: PrometheusMeterRegistry, metricsNam ZIO.executor.flatMap(executor => BlazeServerBuilder[Task] .withExecutionContext(executor.asExecutionContext) + .withServiceErrorHandler(CustomServerInterceptors.http4sServiceErrorHandler) .bindHttp(port, "0.0.0.0") .withHttpApp(Router("/" -> http4sEndpoints).orNotFound) .serve diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobsHelper.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobsHelper.scala index 1708ca1517..67867bb2fb 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobsHelper.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobsHelper.scala @@ -19,7 +19,6 @@ import org.hyperledger.identus.mercury.protocol.invitation.v2.Invitation import org.hyperledger.identus.pollux.core.model.error.{CredentialServiceError, PresentationError} import org.hyperledger.identus.pollux.core.model.DidCommID import org.hyperledger.identus.pollux.core.service.CredentialService -import org.hyperledger.identus.pollux.sdjwt.SDJWT.* import org.hyperledger.identus.pollux.vc.jwt.{ DIDResolutionFailed, DIDResolutionSucceeded, @@ -29,8 +28,11 @@ import org.hyperledger.identus.pollux.vc.jwt.{ * } import org.hyperledger.identus.shared.crypto.* +import org.hyperledger.identus.shared.messaging.ConsumerJobConfig +import org.hyperledger.identus.shared.messaging.MessagingService.RetryStep import org.hyperledger.identus.shared.models.{KeyId, WalletAccessContext} -import zio.{ZIO, ZLayer} +import zio.{durationInt, Duration, ZIO, ZLayer} +import zio.prelude.OrdOps import java.time.Instant import java.util.Base64 @@ -229,4 +231,20 @@ trait BackgroundJobsHelper { case _ => ZIO.unit } } + + def retryStepsFromConfig(topicName: String, jobConfig: ConsumerJobConfig): Seq[RetryStep] = { + val retryTopics = jobConfig.retryStrategy match + case None => Seq.empty + case Some(rs) => + (1 to rs.maxRetries).map(i => + ( + s"$topicName-retry-$i", + rs.initialDelay.multipliedBy(Math.pow(2, i - 1).toLong).min(rs.maxDelay) + ) + ) + val topics = retryTopics prepended (topicName, 0.seconds) appended (s"$topicName-DLQ", Duration.Infinity) + (0 until topics.size - 1).map { i => + RetryStep(topics(i)._1, jobConfig.consumerCount, topics(i)._2, topics(i + 1)._1) + } + } } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/ConnectBackgroundJobs.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/ConnectBackgroundJobs.scala index 46335fd059..07cfd05a22 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/ConnectBackgroundJobs.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/ConnectBackgroundJobs.scala @@ -2,49 +2,63 @@ package org.hyperledger.identus.agent.server.jobs import org.hyperledger.identus.agent.server.config.AppConfig import org.hyperledger.identus.agent.server.jobs.BackgroundJobError.ErrorResponseReceivedFromPeerAgent -import org.hyperledger.identus.agent.walletapi.model.error.DIDSecretStorageError -import org.hyperledger.identus.agent.walletapi.model.error.DIDSecretStorageError.{KeyNotFoundError, WalletNotFoundError} import org.hyperledger.identus.agent.walletapi.service.ManagedDIDService import org.hyperledger.identus.agent.walletapi.storage.DIDNonSecretStorage -import org.hyperledger.identus.connect.core.model.error.ConnectionServiceError.{ - InvalidStateForOperation, - RecordIdNotFound -} import org.hyperledger.identus.connect.core.model.ConnectionRecord import org.hyperledger.identus.connect.core.model.ConnectionRecord.* import org.hyperledger.identus.connect.core.service.ConnectionService import org.hyperledger.identus.mercury.* -import org.hyperledger.identus.mercury.model.error.SendMessageError import org.hyperledger.identus.resolvers.DIDResolver -import org.hyperledger.identus.shared.models.WalletAccessContext +import org.hyperledger.identus.shared.messaging +import org.hyperledger.identus.shared.messaging.{Message, WalletIdAndRecordId} +import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import org.hyperledger.identus.shared.utils.aspects.CustomMetricsAspect import org.hyperledger.identus.shared.utils.DurationOps.toMetricsSeconds import zio.* import zio.metrics.* +import java.util.UUID + object ConnectBackgroundJobs extends BackgroundJobsHelper { - val didCommExchanges = { - for { - connectionService <- ZIO.service[ConnectionService] - config <- ZIO.service[AppConfig] - records <- connectionService - .findRecordsByStatesForAllWallets( - ignoreWithZeroRetries = true, - limit = config.connect.connectBgJobRecordsLimit, - ConnectionRecord.ProtocolState.ConnectionRequestPending, - ConnectionRecord.ProtocolState.ConnectionResponsePending - ) - _ <- ZIO.foreachPar(records)(performExchange).withParallelism(config.connect.connectBgJobProcessingParallelism) - } yield () - } + private val TOPIC_NAME = "connect" + + val connectFlowsHandler = for { + appConfig <- ZIO.service[AppConfig] + _ <- messaging.MessagingService.consumeWithRetryStrategy( + "identus-cloud-agent", + ConnectBackgroundJobs.handleMessage, + retryStepsFromConfig(TOPIC_NAME, appConfig.agent.messagingService.connectFlow) + ) + } yield () - private def performExchange( - record: ConnectionRecord - ): URIO[ + private def handleMessage(message: Message[UUID, WalletIdAndRecordId]): RIO[ DidOps & DIDResolver & HttpClient & ConnectionService & ManagedDIDService & DIDNonSecretStorage & AppConfig, Unit - ] = { + ] = + (for { + _ <- ZIO.logDebug(s"!!! Handling recordId: ${message.value} via Kafka queue") + connectionService <- ZIO.service[ConnectionService] + walletAccessContext = WalletAccessContext(WalletId.fromUUID(message.value.walletId)) + record <- connectionService + .findRecordById(message.value.recordId) + .provideSome(ZLayer.succeed(walletAccessContext)) + .someOrElseZIO(ZIO.dieMessage("Record Not Found")) + _ <- performExchange(record) + .tapSomeError { case (walletAccessContext, errorResponse) => + for { + connectService <- ZIO.service[ConnectionService] + _ <- connectService + .reportProcessingFailure(record.id, Some(errorResponse)) + .provideSomeLayer(ZLayer.succeed(walletAccessContext)) + } yield () + } + .catchAll { e => ZIO.fail(RuntimeException(s"Attempt failed with: ${e}")) } + } yield ()) @@ Metric + .gauge("connection_flow_did_com_exchange_job_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) + + private def performExchange(record: ConnectionRecord) = { import ProtocolState.* import Role.* @@ -179,26 +193,10 @@ object ConnectBackgroundJobs extends BackgroundJobsHelper { @@ Metric .gauge("connection_flow_inviter_process_connection_record_ms_gauge") .trackDurationWith(_.toMetricsSeconds) - case _ => ZIO.unit + case r => ZIO.logWarning(s"Invalid candidate record received for processing: $r") *> ZIO.unit } exchange - .tapError({ - case walletNotFound: WalletNotFoundError => - ZIO.logErrorCause( - s"Connect - Error processing record: ${record.id}", - Cause.fail(walletNotFound) - ) - case ((walletAccessContext, errorResponse)) => - for { - connectService <- ZIO.service[ConnectionService] - _ <- connectService - .reportProcessingFailure(record.id, Some(errorResponse)) - .provideSomeLayer(ZLayer.succeed(walletAccessContext)) - } yield () - }) - .catchAll(e => ZIO.logErrorCause(s"Connect - Error processing record: ${record.id} ", Cause.fail(e))) - .catchAllDefect(d => ZIO.logErrorCause(s"Connect - Defect processing record: ${record.id}", Cause.fail(d))) } } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/DIDStateSyncBackgroundJobs.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/DIDStateSyncBackgroundJobs.scala index 5d4ff494ea..2ee44e91bc 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/DIDStateSyncBackgroundJobs.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/DIDStateSyncBackgroundJobs.scala @@ -1,17 +1,54 @@ package org.hyperledger.identus.agent.server.jobs -import org.hyperledger.identus.agent.walletapi.model.error.GetManagedDIDError -import org.hyperledger.identus.agent.walletapi.service.ManagedDIDService -import org.hyperledger.identus.shared.models.WalletAccessContext +import org.hyperledger.identus.agent.server.config.AppConfig +import org.hyperledger.identus.agent.walletapi.service.{ManagedDIDService, WalletManagementService} +import org.hyperledger.identus.shared.messaging.{Message, MessagingService, Producer} +import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletAdministrationContext, WalletId} +import org.hyperledger.identus.shared.utils.DurationOps.toMetricsSeconds import zio.* +import zio.metrics.Metric -object DIDStateSyncBackgroundJobs { +object DIDStateSyncBackgroundJobs extends BackgroundJobsHelper { - val syncDIDPublicationStateFromDlt: ZIO[WalletAccessContext with ManagedDIDService, GetManagedDIDError, Unit] = - for { + private val TOPIC_NAME = "sync-did-state" + + val didStateSyncTrigger = { + (for { + config <- ZIO.service[AppConfig] + producer <- ZIO.service[Producer[WalletId, WalletId]] + trigger = for { + walletManagementService <- ZIO.service[WalletManagementService] + wallets <- walletManagementService.listWallets().map(_._1) + _ <- ZIO.logInfo(s"Triggering DID state sync for '${wallets.size}' wallets") + _ <- ZIO.foreach(wallets)(w => producer.produce(TOPIC_NAME, w.id, w.id)) + } yield () + _ <- trigger + .catchAll(e => ZIO.logError(s"error while syncing DID publication state: $e")) + .provideSomeLayer(ZLayer.succeed(WalletAdministrationContext.Admin())) + .repeat(Schedule.spaced(config.pollux.didStateSyncTriggerRecurrenceDelay)) + } yield ()).debug.fork + } + + val didStateSyncHandler = for { + appConfig <- ZIO.service[AppConfig] + _ <- MessagingService.consumeWithRetryStrategy( + "identus-cloud-agent", + DIDStateSyncBackgroundJobs.handleMessage, + retryStepsFromConfig(TOPIC_NAME, appConfig.agent.messagingService.didStateSync) + ) + } yield () + + private def handleMessage(message: Message[WalletId, WalletId]): RIO[ManagedDIDService, Unit] = { + val effect = for { managedDidService <- ZIO.service[ManagedDIDService] _ <- managedDidService.syncManagedDIDState _ <- managedDidService.syncUnconfirmedUpdateOperations } yield () - + effect + .provideSomeLayer(ZLayer.succeed(WalletAccessContext(message.value))) + .catchAll(t => ZIO.logErrorCause("Unable to syncing DID publication state", Cause.fail(t))) + @@ Metric + .gauge("did_publication_state_sync_job_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) + } } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/IssueBackgroundJobs.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/IssueBackgroundJobs.scala index ffc617df3c..3cdde2853f 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/IssueBackgroundJobs.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/IssueBackgroundJobs.scala @@ -2,40 +2,61 @@ package org.hyperledger.identus.agent.server.jobs import org.hyperledger.identus.agent.server.config.AppConfig import org.hyperledger.identus.agent.server.jobs.BackgroundJobError.ErrorResponseReceivedFromPeerAgent -import org.hyperledger.identus.agent.walletapi.model.error.DIDSecretStorageError.WalletNotFoundError -import org.hyperledger.identus.castor.core.model.did.* +import org.hyperledger.identus.agent.walletapi.service.ManagedDIDService +import org.hyperledger.identus.agent.walletapi.storage.DIDNonSecretStorage import org.hyperledger.identus.mercury.* -import org.hyperledger.identus.mercury.protocol.issuecredential.* import org.hyperledger.identus.pollux.core.model.* import org.hyperledger.identus.pollux.core.model.error.CredentialServiceError import org.hyperledger.identus.pollux.core.service.CredentialService -import org.hyperledger.identus.shared.models.Failure +import org.hyperledger.identus.resolvers.DIDResolver +import org.hyperledger.identus.shared.messaging +import org.hyperledger.identus.shared.messaging.{Message, WalletIdAndRecordId} +import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import org.hyperledger.identus.shared.utils.aspects.CustomMetricsAspect import org.hyperledger.identus.shared.utils.DurationOps.toMetricsSeconds import zio.* import zio.metrics.* +import java.util.UUID + object IssueBackgroundJobs extends BackgroundJobsHelper { - val issueCredentialDidCommExchanges = { - for { + private val TOPIC_NAME = "issue" + + val issueFlowsHandler = for { + appConfig <- ZIO.service[AppConfig] + _ <- messaging.MessagingService.consumeWithRetryStrategy( + "identus-cloud-agent", + IssueBackgroundJobs.handleMessage, + retryStepsFromConfig(TOPIC_NAME, appConfig.agent.messagingService.issueFlow) + ) + } yield () + + private def handleMessage(message: Message[UUID, WalletIdAndRecordId]): RIO[ + HttpClient & DidOps & DIDResolver & (CredentialService & DIDNonSecretStorage & (ManagedDIDService & AppConfig)), + Unit + ] = { + (for { + _ <- ZIO.logDebug(s"!!! Handling recordId: ${message.value} via Kafka queue") credentialService <- ZIO.service[CredentialService] - config <- ZIO.service[AppConfig] - records <- credentialService - .getIssueCredentialRecordsByStatesForAllWallets( - ignoreWithZeroRetries = true, - limit = config.pollux.issueBgJobRecordsLimit, - IssueCredentialRecord.ProtocolState.OfferPending, - IssueCredentialRecord.ProtocolState.RequestPending, - IssueCredentialRecord.ProtocolState.RequestGenerated, - IssueCredentialRecord.ProtocolState.RequestReceived, - IssueCredentialRecord.ProtocolState.CredentialPending, - IssueCredentialRecord.ProtocolState.CredentialGenerated - ) - _ <- ZIO - .foreachPar(records)(performIssueCredentialExchange) - .withParallelism(config.pollux.issueBgJobProcessingParallelism) - } yield () + walletAccessContext = WalletAccessContext(WalletId.fromUUID(message.value.walletId)) + record <- credentialService + .findById(DidCommID(message.value.recordId.toString)) + .provideSome(ZLayer.succeed(walletAccessContext)) + .someOrElseZIO(ZIO.dieMessage(s"Record Not Found: ${message.value.recordId}")) + _ <- performIssueCredentialExchange(record) + .tapSomeError { case (walletAccessContext, errorResponse) => + for { + credentialService <- ZIO.service[CredentialService] + _ <- credentialService + .reportProcessingFailure(record.id, Some(errorResponse)) + .provideSomeLayer(ZLayer.succeed(walletAccessContext)) + } yield () + } + .catchAll { e => ZIO.fail(RuntimeException(s"Attempt failed with: ${e}")) } + } yield ()) @@ Metric + .gauge("issuance_flow_did_com_exchange_job_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) } private def counterMetric(key: String) = Metric @@ -136,7 +157,7 @@ object IssueBackgroundJobs extends BackgroundJobsHelper { "issuance_flow_issuer_send_credential_msg_succeed_counter" ) - val aux = for { + val exchange = for { _ <- ZIO.logDebug(s"Running action with records => $record") _ <- record match { // Offer should be sent from Issuer to Holder @@ -227,8 +248,8 @@ object IssueBackgroundJobs extends BackgroundJobsHelper { val holderPendingToGeneratedFlow = for { walletAccessContext <- ZIO .fromOption(offer.to) + .mapError(_ => CredentialServiceError.CredentialOfferMissingField(id.value, "recipient")) .flatMap(buildWalletAccessContextLayer) - .mapError(e => CredentialServiceError.CredentialOfferMissingField(id.value, "recipient")) result <- for { credentialService <- ZIO.service[CredentialService] _ <- credentialService @@ -273,8 +294,8 @@ object IssueBackgroundJobs extends BackgroundJobsHelper { val holderPendingToGeneratedFlow = for { walletAccessContext <- ZIO .fromOption(offer.to) + .mapError(_ => CredentialServiceError.CredentialOfferMissingField(id.value, "recipient")) .flatMap(buildWalletAccessContextLayer) - .mapError(e => CredentialServiceError.CredentialOfferMissingField(id.value, "recipient")) result <- for { credentialService <- ZIO.service[CredentialService] _ <- credentialService @@ -319,8 +340,8 @@ object IssueBackgroundJobs extends BackgroundJobsHelper { val holderPendingToGeneratedFlow = for { walletAccessContext <- ZIO .fromOption(offer.to) + .mapError(_ => CredentialServiceError.CredentialOfferMissingField(id.value, "recipient")) .flatMap(buildWalletAccessContextLayer) - .mapError(e => CredentialServiceError.CredentialOfferMissingField(id.value, "recipient")) result <- for { credentialService <- ZIO.service[CredentialService] @@ -629,33 +650,12 @@ object IssueBackgroundJobs extends BackgroundJobsHelper { @@ IssuerSendCredentialAll @@ Metric .gauge("issuance_flow_issuer_send_cred_flow_ms_gauge") .trackDurationWith(_.toMetricsSeconds) - - case record: IssueCredentialRecord => - ZIO.logDebug(s"IssuanceRecord: ${record.id} - ${record.protocolState}") *> ZIO.unit + case r: IssueCredentialRecord => + ZIO.logWarning(s"Invalid candidate record received for processing: $r") *> ZIO.unit } } yield () - aux - .tapError( - { - case walletNotFound: WalletNotFoundError => ZIO.unit - case CredentialServiceError.RecordNotFound(_, _) => ZIO.unit - case CredentialServiceError.UnsupportedDidFormat(_) => ZIO.unit - case failure: Failure => ZIO.unit - case ((walletAccessContext, failure)) => - for { - credentialService <- ZIO.service[CredentialService] - _ <- credentialService - .reportProcessingFailure(record.id, Some(failure)) - .provideSomeLayer(ZLayer.succeed(walletAccessContext)) - } yield () - } - ) - .catchAll(e => ZIO.logErrorCause(s"Issue Credential - Error processing record: ${record.id} ", Cause.fail(e))) - .catchAllDefect(d => - ZIO.logErrorCause(s"Issue Credential - Defect processing record: ${record.id}", Cause.fail(d)) - ) - + exchange } } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala index 9938b6b50b..4bfb247176 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala @@ -30,17 +30,18 @@ import org.hyperledger.identus.pollux.sdjwt.{HolderPrivateKey, IssuerPublicKey, import org.hyperledger.identus.pollux.vc.jwt.{DidResolver as JwtDidResolver, Issuer as JwtIssuer, JWT, JwtPresentation} import org.hyperledger.identus.resolvers.DIDResolver import org.hyperledger.identus.shared.http.* -import org.hyperledger.identus.shared.models.* +import org.hyperledger.identus.shared.messaging +import org.hyperledger.identus.shared.messaging.{Message, WalletIdAndRecordId} +import org.hyperledger.identus.shared.models.{Failure, *} import org.hyperledger.identus.shared.utils.aspects.CustomMetricsAspect import org.hyperledger.identus.shared.utils.DurationOps.toMetricsSeconds import zio.* -import zio.json.* -import zio.json.ast.Json import zio.metrics.* import zio.prelude.Validation import zio.prelude.ZValidation.{Failure as ZFailure, *} -import java.time.{Clock, Instant, ZoneId} +import java.time.{Instant, ZoneId} +import java.util.UUID object PresentBackgroundJobs extends BackgroundJobsHelper { @@ -55,47 +56,50 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { private type MESSAGING_RESOURCES = DidOps & DIDResolver & HttpClient - val presentProofExchanges: ZIO[RESOURCES, Throwable, Unit] = { - for { + private val TOPIC_NAME = "present" + + val presentFlowsHandler = for { + appConfig <- ZIO.service[AppConfig] + _ <- messaging.MessagingService.consumeWithRetryStrategy( + "identus-cloud-agent", + PresentBackgroundJobs.handleMessage, + retryStepsFromConfig(TOPIC_NAME, appConfig.agent.messagingService.presentFlow) + ) + } yield () + + private def handleMessage(message: Message[UUID, WalletIdAndRecordId]): RIO[ + RESOURCES, + Unit + ] = { + (for { + _ <- ZIO.logDebug(s"!!! Present Proof Handling recordId: ${message.value} via Kafka queue") presentationService <- ZIO.service[PresentationService] - config <- ZIO.service[AppConfig] - records <- presentationService - .getPresentationRecordsByStatesForAllWallets( - ignoreWithZeroRetries = true, - limit = config.pollux.presentationBgJobRecordsLimit, - PresentationRecord.ProtocolState.RequestPending, - PresentationRecord.ProtocolState.PresentationPending, - PresentationRecord.ProtocolState.PresentationGenerated, - PresentationRecord.ProtocolState.PresentationReceived - ) - .mapError(err => Throwable(s"Error occurred while getting Presentation records: $err")) - _ <- ZIO.logInfo(s"Processing ${records.size} Presentation records") - _ <- ZIO - .foreachPar(records)(performPresentProofExchange) - .withParallelism(config.pollux.presentationBgJobProcessingParallelism) - } yield () + walletAccessContext = WalletAccessContext(WalletId.fromUUID(message.value.walletId)) + record <- presentationService + .findPresentationRecord(DidCommID(message.value.recordId.toString)) + .provideSome(ZLayer.succeed(walletAccessContext)) + .someOrElseZIO(ZIO.dieMessage("Record Not Found")) + _ <- performPresentProofExchange(record) + .tapSomeError { case f: Failure => + for { + presentationService <- ZIO.service[PresentationService] + _ <- presentationService + .reportProcessingFailure(record.id, Some(f)) + } yield () + } + .catchAll { e => ZIO.fail(RuntimeException(s"Attempt failed with: ${e}")) } + } yield ()) @@ Metric + .gauge("present_proof_flow_did_com_exchange_job_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) } private def counterMetric(key: String) = Metric .counterInt(key) .fromConst(1) - private def performPresentProofExchange(record: PresentationRecord): URIO[RESOURCES, Unit] = - aux(record) - .catchAll { - case ex: Failure => - ZIO - .service[PresentationService] - .flatMap(_.reportProcessingFailure(record.id, Some(ex))) - case ex => ZIO.logErrorCause(s"PresentBackgroundJobs - Error processing record: ${record.id}", Cause.fail(ex)) - } - .catchAllDefect(d => - ZIO.logErrorCause(s"PresentBackgroundJobs - Defect processing record: ${record.id}", Cause.fail(d)) - ) - - private def aux(record: PresentationRecord): ZIO[RESOURCES, ERROR, Unit] = { + private def performPresentProofExchange(record: PresentationRecord): ZIO[RESOURCES, ERROR, Unit] = { import org.hyperledger.identus.pollux.core.model.PresentationRecord.ProtocolState.* - for { + val exchange = for { _ <- ZIO.logDebug(s"Running action with records => $record") _ <- record match { case PresentationRecord( @@ -604,6 +608,8 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { ZIO.logWarning(s"Unhandled PresentationRecord state: ${record.protocolState}") } } yield () + + exchange } object Prover { diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/StatusListJobs.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/StatusListJobs.scala index 71d02db3e2..1fe5d77551 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/StatusListJobs.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/StatusListJobs.scala @@ -1,131 +1,181 @@ package org.hyperledger.identus.agent.server.jobs import org.hyperledger.identus.agent.server.config.AppConfig +import org.hyperledger.identus.agent.walletapi.service.ManagedDIDService import org.hyperledger.identus.castor.core.model.did.VerificationRelationship +import org.hyperledger.identus.castor.core.service.DIDService import org.hyperledger.identus.mercury.* import org.hyperledger.identus.mercury.protocol.revocationnotificaiton.RevocationNotification +import org.hyperledger.identus.pollux.core.model.{CredInStatusList, CredentialStatusListWithCreds} import org.hyperledger.identus.pollux.core.service.{CredentialService, CredentialStatusListService} -import org.hyperledger.identus.pollux.vc.jwt.revocation.{VCStatusList2021, VCStatusList2021Error} -import org.hyperledger.identus.shared.models.* +import org.hyperledger.identus.pollux.vc.jwt.revocation.{BitString, VCStatusList2021, VCStatusList2021Error} +import org.hyperledger.identus.resolvers.DIDResolver +import org.hyperledger.identus.shared.messaging +import org.hyperledger.identus.shared.messaging.{Message, Producer, WalletIdAndRecordId} +import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import org.hyperledger.identus.shared.utils.DurationOps.toMetricsSeconds import zio.* import zio.metrics.Metric +import java.util.UUID + object StatusListJobs extends BackgroundJobsHelper { - val syncRevocationStatuses = - for { - credentialStatusListService <- ZIO.service[CredentialStatusListService] - credentialService <- ZIO.service[CredentialService] - credentialStatusListsWithCreds <- credentialStatusListService.getCredentialsAndItsStatuses - @@ Metric - .gauge("revocation_status_list_sync_get_status_lists_w_creds_ms_gauge") - .trackDurationWith(_.toMetricsSeconds) + private val TOPIC_NAME = "sync-status-list" - updatedVcStatusListsCredsEffects = credentialStatusListsWithCreds.map { statusListWithCreds => - val vcStatusListCredString = statusListWithCreds.statusListCredential - val walletAccessContext = WalletAccessContext(statusListWithCreds.walletId) + val statusListsSyncTrigger = { + (for { + config <- ZIO.service[AppConfig] + producer <- ZIO.service[Producer[UUID, WalletIdAndRecordId]] + trigger = for { + credentialStatusListService <- ZIO.service[CredentialStatusListService] + walletAndStatusListIds <- credentialStatusListService.getCredentialStatusListIds + _ <- ZIO.logInfo(s"Triggering status list revocation sync for '${walletAndStatusListIds.size}' status lists") + _ <- ZIO.foreach(walletAndStatusListIds) { (walletId, statusListId) => + producer.produce(TOPIC_NAME, walletId.toUUID, WalletIdAndRecordId(walletId.toUUID, statusListId)) + } + } yield () + _ <- trigger.repeat(Schedule.spaced(config.pollux.statusListSyncTriggerRecurrenceDelay)) + } yield ()).debug.fork + } - val effect = for { - vcStatusListCredJson <- ZIO - .fromEither(io.circe.parser.parse(vcStatusListCredString)) - .mapError(_.underlying) - issuer <- createJwtVcIssuer( - statusListWithCreds.issuer, - VerificationRelationship.AssertionMethod, - None - ) - vcStatusListCred <- VCStatusList2021 - .decodeFromJson(vcStatusListCredJson, issuer) - .mapError(x => new Throwable(x.msg)) - bitString <- vcStatusListCred.getBitString.mapError(x => new Throwable(x.msg)) - updateBitStringEffects = statusListWithCreds.credentials.map { cred => - if cred.isCanceled then { - val sendMessageEffect = for { - maybeIssueCredentialRecord <- credentialService.findById(cred.issueCredentialRecordId) - issueCredentialRecord <- ZIO - .fromOption(maybeIssueCredentialRecord) - .mapError(_ => - new Throwable(s"Issue credential record not found by id: ${cred.issueCredentialRecordId}") - ) - issueCredentialData <- ZIO - .fromOption(issueCredentialRecord.issueCredentialData) - .mapError(_ => - new Throwable( - s"Issue credential data not found in issue credential record by id: ${cred.issueCredentialRecordId}" - ) - ) - issueCredentialProtocolThreadId <- ZIO - .fromOption(issueCredentialData.thid) - .mapError(_ => new Throwable("thid not found in issue credential data")) - revocationNotification = RevocationNotification.build( - issueCredentialData.from, - issueCredentialData.to, - issueCredentialProtocolThreadId = issueCredentialProtocolThreadId - ) - didCommAgent <- buildDIDCommAgent(issueCredentialData.from) - response <- MessagingService - .send(revocationNotification.makeMessage) - .provideSomeLayer(didCommAgent) @@ Metric - .gauge("revocation_status_list_sync_revocation_notification_ms_gauge") - .trackDurationWith(_.toMetricsSeconds) - } yield response + val statusListSyncHandler = for { + appConfig <- ZIO.service[AppConfig] + _ <- messaging.MessagingService.consumeWithRetryStrategy( + "identus-cloud-agent", + StatusListJobs.handleMessage, + retryStepsFromConfig(TOPIC_NAME, appConfig.agent.messagingService.statusListSync) + ) + } yield () - val updateBitStringEffect = bitString.setRevokedInPlace(cred.statusListIndex, true) + private def handleMessage(message: Message[UUID, WalletIdAndRecordId]): RIO[ + DIDService & ManagedDIDService & CredentialService & DidOps & DIDResolver & HttpClient & + CredentialStatusListService, + Unit + ] = { + (for { + _ <- ZIO.logDebug(s"!!! Handling recordId: ${message.value} via Kafka queue") + credentialStatusListService <- ZIO.service[CredentialStatusListService] + walletAccessContext = WalletAccessContext(WalletId.fromUUID(message.value.walletId)) + statusListWithCreds <- credentialStatusListService + .getCredentialStatusListWithCreds(message.value.recordId) + .provideSome(ZLayer.succeed(walletAccessContext)) + _ <- updateStatusList(statusListWithCreds) + } yield ()) @@ Metric + .gauge("revocation_status_list_sync_job_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) + } - val updateAndNotify = for { - updated <- updateBitStringEffect.mapError(x => new Throwable(x.message)) - _ <- - if !cred.isProcessed then - sendMessageEffect.flatMap { resp => - if (resp.status >= 200 && resp.status < 300) - ZIO.logInfo("successfully sent revocation notification message") - else ZIO.logError(s"failed to send revocation notification message") - } - else ZIO.unit - } yield updated - updateAndNotify.provideSomeLayer(ZLayer.succeed(walletAccessContext)) @@ Metric - .gauge("revocation_status_list_sync_process_single_credential_ms_gauge") - .trackDurationWith(_.toMetricsSeconds) - } else ZIO.unit - } - _ <- ZIO - .collectAll(updateBitStringEffects) + private def updateStatusList(statusListWithCreds: CredentialStatusListWithCreds) = { + for { + credentialStatusListService <- ZIO.service[CredentialStatusListService] + vcStatusListCredString = statusListWithCreds.statusListCredential + walletAccessContext = WalletAccessContext(statusListWithCreds.walletId) + effect = for { + vcStatusListCredJson <- ZIO + .fromEither(io.circe.parser.parse(vcStatusListCredString)) + .mapError(_.underlying) + issuer <- createJwtVcIssuer(statusListWithCreds.issuer, VerificationRelationship.AssertionMethod, None) + vcStatusListCred <- VCStatusList2021 + .decodeFromJson(vcStatusListCredJson, issuer) + .mapError(x => new Throwable(x.msg)) + bitString <- vcStatusListCred.getBitString.mapError(x => new Throwable(x.msg)) + _ <- ZIO.collectAll( + statusListWithCreds.credentials.map(c => + updateBitStringForCredentialAndNotify(bitString, c, walletAccessContext) + ) + ) + unprocessedEntityIds = statusListWithCreds.credentials.collect { + case x if !x.isProcessed && x.isCanceled => x.id + } + _ <- credentialStatusListService + .markAsProcessedMany(unprocessedEntityIds) + @@ Metric + .gauge("revocation_status_list_sync_mark_as_processed_many_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) - unprocessedEntityIds = statusListWithCreds.credentials.collect { - case x if !x.isProcessed && x.isCanceled => x.id - } - _ <- credentialStatusListService - .markAsProcessedMany(unprocessedEntityIds) - @@ Metric - .gauge("revocation_status_list_sync_mark_as_processed_many_ms_gauge") - .trackDurationWith(_.toMetricsSeconds) + updatedVcStatusListCred <- vcStatusListCred.updateBitString(bitString).mapError { + case VCStatusList2021Error.EncodingError(msg: String) => new Throwable(msg) + case VCStatusList2021Error.DecodingError(msg: String) => new Throwable(msg) + } + vcStatusListCredJsonString <- updatedVcStatusListCred.toJsonWithEmbeddedProof.map(_.spaces2) + _ <- credentialStatusListService.updateStatusListCredential( + statusListWithCreds.id, + vcStatusListCredJsonString + ) + } yield () + _ <- effect + .catchAll(e => + ZIO.logErrorCause(s"Error processing status list record: ${statusListWithCreds.id} ", Cause.fail(e)) + ) + .catchAllDefect(d => + ZIO.logErrorCause(s"Defect processing status list record: ${statusListWithCreds.id}", Cause.fail(d)) + ) + .provideSomeLayer(ZLayer.succeed(walletAccessContext)) + } yield () + } - updatedVcStatusListCred <- vcStatusListCred.updateBitString(bitString).mapError { - case VCStatusList2021Error.EncodingError(msg: String) => new Throwable(msg) - case VCStatusList2021Error.DecodingError(msg: String) => new Throwable(msg) - } - vcStatusListCredJsonString <- updatedVcStatusListCred.toJsonWithEmbeddedProof - .map(_.spaces2) - _ <- credentialStatusListService - .updateStatusListCredential(statusListWithCreds.id, vcStatusListCredJsonString) - } yield () + private def updateBitStringForCredentialAndNotify( + bitString: BitString, + credInStatusList: CredInStatusList, + walletAccessContext: WalletAccessContext + ) = { + for { + credentialService <- ZIO.service[CredentialService] + _ <- + if credInStatusList.isCanceled then { + val updateBitStringEffect = bitString.setRevokedInPlace(credInStatusList.statusListIndex, true) + val notifyEffect = sendRevocationNotificationMessage(credInStatusList) + val updateAndNotify = for { + updated <- updateBitStringEffect.mapError(x => new Throwable(x.message)) + _ <- + if !credInStatusList.isProcessed then + notifyEffect.flatMap { resp => + if (resp.status >= 200 && resp.status < 300) + ZIO.logInfo("successfully sent revocation notification message") + else ZIO.logError(s"failed to send revocation notification message") + } + else ZIO.unit + } yield updated + updateAndNotify.provideSomeLayer(ZLayer.succeed(walletAccessContext)) @@ Metric + .gauge("revocation_status_list_sync_process_single_credential_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) + } else ZIO.unit + } yield () + } - effect - .catchAll(e => - ZIO.logErrorCause(s"Error processing status list record: ${statusListWithCreds.id} ", Cause.fail(e)) - ) - .catchAllDefect(d => - ZIO.logErrorCause(s"Defect processing status list record: ${statusListWithCreds.id}", Cause.fail(d)) + private def sendRevocationNotificationMessage( + credInStatusList: CredInStatusList + ) = { + for { + credentialService <- ZIO.service[CredentialService] + maybeIssueCredentialRecord <- credentialService.findById(credInStatusList.issueCredentialRecordId) + issueCredentialRecord <- ZIO + .fromOption(maybeIssueCredentialRecord) + .mapError(_ => + new Throwable(s"Issue credential record not found by id: ${credInStatusList.issueCredentialRecordId}") + ) + issueCredentialData <- ZIO + .fromOption(issueCredentialRecord.issueCredentialData) + .mapError(_ => + new Throwable( + s"Issue credential data not found in issue credential record by id: ${credInStatusList.issueCredentialRecordId}" ) - .provideSomeLayer(ZLayer.succeed(walletAccessContext)) - - } - config <- ZIO.service[AppConfig] - _ <- (ZIO - .collectAll(updatedVcStatusListsCredsEffects) @@ Metric - .gauge("revocation_status_list_sync_process_status_lists_w_creds_ms_gauge") - .trackDurationWith(_.toMetricsSeconds)) - .withParallelism(config.pollux.syncRevocationStatusesBgJobProcessingParallelism) - } yield () + ) + issueCredentialProtocolThreadId <- ZIO + .fromOption(issueCredentialData.thid) + .mapError(_ => new Throwable("thid not found in issue credential data")) + revocationNotification = RevocationNotification.build( + issueCredentialData.from, + issueCredentialData.to, + issueCredentialProtocolThreadId = issueCredentialProtocolThreadId + ) + didCommAgent <- buildDIDCommAgent(issueCredentialData.from) + response <- MessagingService + .send(revocationNotification.makeMessage) + .provideSomeLayer(didCommAgent) @@ Metric + .gauge("revocation_status_list_sync_revocation_notification_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) + } yield response + } } diff --git a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/issue/controller/IssueControllerTestTools.scala b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/issue/controller/IssueControllerTestTools.scala index c7d1bd7a8f..4cb227e8ff 100644 --- a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/issue/controller/IssueControllerTestTools.scala +++ b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/issue/controller/IssueControllerTestTools.scala @@ -69,9 +69,9 @@ trait IssueControllerTestTools extends PostgresTestContainerSupport { def bootstrapOptions[F[_]](monadError: MonadError[F]): CustomiseInterceptors[F, Any] = { new CustomiseInterceptors[F, Any](_ => ()) - .exceptionHandler(CustomServerInterceptors.exceptionHandler) - .rejectHandler(CustomServerInterceptors.rejectHandler) - .decodeFailureHandler(CustomServerInterceptors.decodeFailureHandler) + .exceptionHandler(CustomServerInterceptors.tapirExceptionHandler) + .rejectHandler(CustomServerInterceptors.tapirRejectHandler) + .decodeFailureHandler(CustomServerInterceptors.tapirDecodeFailureHandler) } def httpBackend(controller: IssueController, authenticator: AuthenticatorWithAuthZ[BaseEntity]) = { diff --git a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/oid4vci/domain/OIDCCredentialIssuerServiceSpec.scala b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/oid4vci/domain/OIDCCredentialIssuerServiceSpec.scala index 6facffe5ed..fb4ce6f7ab 100644 --- a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/oid4vci/domain/OIDCCredentialIssuerServiceSpec.scala +++ b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/oid4vci/domain/OIDCCredentialIssuerServiceSpec.scala @@ -12,16 +12,15 @@ import org.hyperledger.identus.oid4vci.storage.InMemoryIssuanceSessionService import org.hyperledger.identus.pollux.core.model.oid4vci.CredentialConfiguration import org.hyperledger.identus.pollux.core.model.CredentialFormat import org.hyperledger.identus.pollux.core.repository.{ - CredentialRepository, CredentialRepositoryInMemory, CredentialStatusListRepositoryInMemory } import org.hyperledger.identus.pollux.core.service.* import org.hyperledger.identus.pollux.core.service.uriResolvers.ResourceUrlResolver import org.hyperledger.identus.pollux.vc.jwt.PrismDidResolver +import org.hyperledger.identus.shared.messaging.{MessagingService, MessagingServiceConfig, WalletIdAndRecordId} import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import zio.{Clock, Random, URLayer, ZIO, ZLayer} -import zio.json.* import zio.json.ast.Json import zio.mock.MockSpecDefault import zio.test.* @@ -54,6 +53,8 @@ object OIDCCredentialIssuerServiceSpec GenericSecretStorageInMemory.layer, LinkSecretServiceImpl.layer, CredentialServiceImpl.layer, + (MessagingServiceConfig.inMemoryLayer >>> MessagingService.serviceLayer >>> + MessagingService.producerLayer[UUID, WalletIdAndRecordId]).orDie, OIDCCredentialIssuerServiceImpl.layer ) diff --git a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/pollux/credentialdefinition/CredentialDefinitionTestTools.scala b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/pollux/credentialdefinition/CredentialDefinitionTestTools.scala index 19636104a5..55b93f3007 100644 --- a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/pollux/credentialdefinition/CredentialDefinitionTestTools.scala +++ b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/pollux/credentialdefinition/CredentialDefinitionTestTools.scala @@ -97,9 +97,9 @@ trait CredentialDefinitionTestTools extends PostgresTestContainerSupport { def bootstrapOptions[F[_]](monadError: MonadError[F]) = { new CustomiseInterceptors[F, Any](_ => ()) - .exceptionHandler(CustomServerInterceptors.exceptionHandler) - .rejectHandler(CustomServerInterceptors.rejectHandler) - .decodeFailureHandler(CustomServerInterceptors.decodeFailureHandler) + .exceptionHandler(CustomServerInterceptors.tapirExceptionHandler) + .rejectHandler(CustomServerInterceptors.tapirRejectHandler) + .decodeFailureHandler(CustomServerInterceptors.tapirDecodeFailureHandler) } def httpBackend( diff --git a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/pollux/schema/CredentialSchemaTestTools.scala b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/pollux/schema/CredentialSchemaTestTools.scala index 5917f13f1c..7f72581960 100644 --- a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/pollux/schema/CredentialSchemaTestTools.scala +++ b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/pollux/schema/CredentialSchemaTestTools.scala @@ -96,9 +96,9 @@ trait CredentialSchemaTestTools extends PostgresTestContainerSupport { def bootstrapOptions[F[_]](monadError: MonadError[F]) = { new CustomiseInterceptors[F, Any](_ => ()) - .exceptionHandler(CustomServerInterceptors.exceptionHandler) - .rejectHandler(CustomServerInterceptors.rejectHandler) - .decodeFailureHandler(CustomServerInterceptors.decodeFailureHandler) + .exceptionHandler(CustomServerInterceptors.tapirExceptionHandler) + .rejectHandler(CustomServerInterceptors.tapirRejectHandler) + .decodeFailureHandler(CustomServerInterceptors.tapirDecodeFailureHandler) } def httpBackend( diff --git a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/system/controller/SystemControllerTestTools.scala b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/system/controller/SystemControllerTestTools.scala index 95ed827fec..80f15ce237 100644 --- a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/system/controller/SystemControllerTestTools.scala +++ b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/system/controller/SystemControllerTestTools.scala @@ -41,9 +41,9 @@ trait SystemControllerTestTools { def bootstrapOptions[F[_]](monadError: MonadError[F]): CustomiseInterceptors[F, Any] = { new CustomiseInterceptors[F, Any](_ => ()) - .exceptionHandler(CustomServerInterceptors.exceptionHandler) - .rejectHandler(CustomServerInterceptors.rejectHandler) - .decodeFailureHandler(CustomServerInterceptors.decodeFailureHandler) + .exceptionHandler(CustomServerInterceptors.tapirExceptionHandler) + .rejectHandler(CustomServerInterceptors.tapirRejectHandler) + .decodeFailureHandler(CustomServerInterceptors.tapirDecodeFailureHandler) } def httpBackend(controller: SystemController) = { diff --git a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/verification/controller/VcVerificationControllerTestTools.scala b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/verification/controller/VcVerificationControllerTestTools.scala index d3ca097e3d..e4da96b640 100644 --- a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/verification/controller/VcVerificationControllerTestTools.scala +++ b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/verification/controller/VcVerificationControllerTestTools.scala @@ -70,9 +70,9 @@ trait VcVerificationControllerTestTools extends PostgresTestContainerSupport { def bootstrapOptions[F[_]](monadError: MonadError[F]): CustomiseInterceptors[F, Any] = { new CustomiseInterceptors[F, Any](_ => ()) - .exceptionHandler(CustomServerInterceptors.exceptionHandler) - .rejectHandler(CustomServerInterceptors.rejectHandler) - .decodeFailureHandler(CustomServerInterceptors.decodeFailureHandler) + .exceptionHandler(CustomServerInterceptors.tapirExceptionHandler) + .rejectHandler(CustomServerInterceptors.tapirRejectHandler) + .decodeFailureHandler(CustomServerInterceptors.tapirDecodeFailureHandler) } def httpBackend(controller: VcVerificationController, authenticator: AuthenticatorWithAuthZ[BaseEntity]) = { diff --git a/cloud-agent/service/wallet-api/src/main/resources/sql/agent/V15__add_did_index_table.sql b/cloud-agent/service/wallet-api/src/main/resources/sql/agent/V15__add_did_index_table.sql new file mode 100644 index 0000000000..5a59c4a121 --- /dev/null +++ b/cloud-agent/service/wallet-api/src/main/resources/sql/agent/V15__add_did_index_table.sql @@ -0,0 +1,19 @@ +-- Last used DID Index per wallet (solving race condition) +CREATE TABLE public.last_did_index_per_wallet +( + "wallet_id" UUID REFERENCES public.wallet ("wallet_id") NOT NULL PRIMARY KEY, + "last_used_index" INT NOT NULL +); + +ALTER TABLE public.last_did_index_per_wallet + ENABLE ROW LEVEL SECURITY; + +CREATE +POLICY last_did_index_per_wallet_wallet_isolation +ON public.last_did_index_per_wallet +USING (wallet_id = current_setting('app.current_wallet_id')::UUID); + +INSERT INTO public.last_did_index_per_wallet(wallet_id, last_used_index) +SELECT wallet_id, MAX(did_index) +FROM public.prism_did_wallet_state +GROUP BY wallet_id; \ No newline at end of file diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/ManagedDIDServiceImpl.scala b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/ManagedDIDServiceImpl.scala index 861f3a8b10..37e8543b94 100644 --- a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/ManagedDIDServiceImpl.scala +++ b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/ManagedDIDServiceImpl.scala @@ -1,8 +1,7 @@ package org.hyperledger.identus.agent.walletapi.service import org.hyperledger.identus.agent.walletapi.model.* -import org.hyperledger.identus.agent.walletapi.model.error.* -import org.hyperledger.identus.agent.walletapi.model.error.given +import org.hyperledger.identus.agent.walletapi.model.error.{*, given} import org.hyperledger.identus.agent.walletapi.service.handler.{DIDCreateHandler, DIDUpdateHandler, PublicationHandler} import org.hyperledger.identus.agent.walletapi.service.ManagedDIDService.DEFAULT_MASTER_KEY_ID import org.hyperledger.identus.agent.walletapi.storage.{DIDNonSecretStorage, DIDSecretStorage, WalletSecretStorage} @@ -32,7 +31,6 @@ class ManagedDIDServiceImpl private[walletapi] ( override private[walletapi] val nonSecretStorage: DIDNonSecretStorage, walletSecretStorage: WalletSecretStorage, apollo: Apollo, - createDIDSem: Semaphore ) extends ManagedDIDService { private val AGREEMENT_KEY_ID = KeyId("agreement") @@ -127,7 +125,7 @@ class ManagedDIDServiceImpl private[walletapi] ( def createAndStoreDID( didTemplate: ManagedDIDTemplate ): ZIO[WalletAccessContext, CreateManagedDIDError, LongFormPrismDID] = { - val effect = for { + for { _ <- ZIO .fromEither(ManagedDIDTemplateValidator.validate(didTemplate, defaultDidDocumentServices)) .mapError { x => @@ -144,15 +142,6 @@ class ManagedDIDServiceImpl private[walletapi] ( .mapError(CreateManagedDIDError.InvalidOperation.apply) _ <- material.persist.mapError(CreateManagedDIDError.WalletStorageError.apply) } yield PrismDID.buildLongFormFromOperation(material.operation) - - // This synchronizes createDID effect to only allow 1 execution at a time - // to avoid concurrent didIndex update. Long-term solution should be - // solved at the DB level. - // - // Performance may be improved by not synchronizing the whole operation, - // but only the counter increment part allowing multiple in-flight create operations - // once didIndex is acquired. - createDIDSem.withPermit(effect) } def updateManagedDID( @@ -385,7 +374,6 @@ object ManagedDIDServiceImpl { nonSecretStorage <- ZIO.service[DIDNonSecretStorage] walletSecretStorage <- ZIO.service[WalletSecretStorage] apollo <- ZIO.service[Apollo] - createDIDSem <- Semaphore.make(1) } yield ManagedDIDServiceImpl( defaultDidDocumentServices, didService, @@ -393,8 +381,7 @@ object ManagedDIDServiceImpl { secretStorage, nonSecretStorage, walletSecretStorage, - apollo, - createDIDSem + apollo ) } } diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/ManagedDIDServiceWithEventNotificationImpl.scala b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/ManagedDIDServiceWithEventNotificationImpl.scala index 795e6d6199..02b5142152 100644 --- a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/ManagedDIDServiceWithEventNotificationImpl.scala +++ b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/ManagedDIDServiceWithEventNotificationImpl.scala @@ -20,7 +20,6 @@ class ManagedDIDServiceWithEventNotificationImpl( override private[walletapi] val nonSecretStorage: DIDNonSecretStorage, walletSecretStorage: WalletSecretStorage, apollo: Apollo, - createDIDSem: Semaphore, eventNotificationService: EventNotificationService ) extends ManagedDIDServiceImpl( defaultDidDocumentServices, @@ -29,8 +28,7 @@ class ManagedDIDServiceWithEventNotificationImpl( secretStorage, nonSecretStorage, walletSecretStorage, - apollo, - createDIDSem + apollo ) { private val didStatusUpdatedEventName = "DIDStatusUpdated" @@ -81,7 +79,6 @@ object ManagedDIDServiceWithEventNotificationImpl { nonSecretStorage, walletSecretStorage, apollo, - createDIDSem, eventNotificationService ) } diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/handler/DIDCreateHandler.scala b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/handler/DIDCreateHandler.scala index 66fec256bb..d87ef1c91d 100644 --- a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/handler/DIDCreateHandler.scala +++ b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/handler/DIDCreateHandler.scala @@ -31,12 +31,7 @@ private[walletapi] class DIDCreateHandler( walletId <- ZIO.serviceWith[WalletAccessContext](_.walletId) seed <- walletSecretStorage.findWalletSeed .someOrElseZIO(ZIO.dieMessage(s"Wallet seed for wallet $walletId does not exist")) - didIndex <- nonSecretStorage - .getMaxDIDIndex() - .mapBoth( - CreateManagedDIDError.WalletStorageError.apply, - maybeIdx => maybeIdx.map(_ + 1).getOrElse(0) - ) + didIndex <- nonSecretStorage.incrementAndGetNextDIDIndex generated <- operationFactory.makeCreateOperation(masterKeyId, seed.toByteArray)(didIndex, didTemplate) (createOperation, keys) = generated state = ManagedDIDState(createOperation, didIndex, PublicationState.Created()) diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/sql/JdbcDIDNonSecretStorage.scala b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/sql/JdbcDIDNonSecretStorage.scala index bfdca44f73..7f86258e80 100644 --- a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/sql/JdbcDIDNonSecretStorage.scala +++ b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/sql/JdbcDIDNonSecretStorage.scala @@ -1,27 +1,21 @@ package org.hyperledger.identus.agent.walletapi.sql +import cats.implicits.toFunctorOps import doobie.* import doobie.implicits.* import doobie.postgres.implicits.* import org.hyperledger.identus.agent.walletapi.model.* import org.hyperledger.identus.agent.walletapi.storage.DIDNonSecretStorage -import org.hyperledger.identus.castor.core.model.did.{ - EllipticCurve, - InternalKeyPurpose, - PrismDID, - ScheduledDIDOperationStatus, - VerificationRelationship -} +import org.hyperledger.identus.castor.core.model.did.* import org.hyperledger.identus.mercury.model.DidId import org.hyperledger.identus.shared.db.ContextAwareTask -import org.hyperledger.identus.shared.db.Implicits.* -import org.hyperledger.identus.shared.db.Implicits.given +import org.hyperledger.identus.shared.db.Implicits.{*, given} import org.hyperledger.identus.shared.models.{KeyId, WalletAccessContext, WalletId} import zio.* import zio.interop.catz.* import java.time.Instant -import scala.collection.immutable.ArraySeq +import java.util.Objects class JdbcDIDNonSecretStorage(xa: Transactor[ContextAwareTask], xb: Transactor[Task]) extends DIDNonSecretStorage { @@ -109,11 +103,11 @@ class JdbcDIDNonSecretStorage(xa: Transactor[ContextAwareTask], xb: Transactor[T _ <- insertHdKeyIO.updateMany(randKeyValues(now)) } yield () - for { + (for { walletCtx <- ZIO.service[WalletAccessContext] now <- Clock.instant _ <- txnIO(now, walletCtx.walletId).transactWallet(xa) - } yield () + } yield ()).orDie } override def updateManagedDID(did: PrismDID, patch: ManagedDIDStatePatch): RIO[WalletAccessContext, Unit] = { @@ -151,6 +145,41 @@ class JdbcDIDNonSecretStorage(xa: Transactor[ContextAwareTask], xb: Transactor[T cxnIO.transactWallet(xa).map(_.flatten) } + override def incrementAndGetNextDIDIndex: URIO[WalletAccessContext, Int] = { + def acquireAdvisoryLock(walletId: WalletId): ConnectionIO[Unit] = { + // Should be specific to this process + val PROCESS_UNIQUE_ID = 465263 + val hashCode = Objects.hash(walletId.hashCode(), PROCESS_UNIQUE_ID) + sql"SELECT pg_advisory_xact_lock($hashCode)".query[Unit].unique.void + } + + def insertWalletDIDIndexIfNotExists(walletId: WalletId): ConnectionIO[Int] = { + sql""" + | INSERT INTO public.last_did_index_per_wallet (wallet_id, last_used_index) + | VALUES ($walletId, -1) + | ON CONFLICT (wallet_id) DO NOTHING""".stripMargin.update.run + } + + def incrementWalletDIDIndex(walletId: WalletId): ConnectionIO[Int] = { + sql""" + | UPDATE public.last_did_index_per_wallet + | SET last_used_index = last_used_index + 1 + | WHERE wallet_id = $walletId + | RETURNING last_used_index""".stripMargin.query[Int].unique + } + + for { + walletCtx <- ZIO.service[WalletAccessContext] + walletId = walletCtx.walletId + cnxIO = for { + _ <- acquireAdvisoryLock(walletId) + _ <- insertWalletDIDIndexIfNotExists(walletId) + index <- incrementWalletDIDIndex(walletId) + } yield index + index <- cnxIO.transactWallet(xa).orDie + } yield index + } + override def getHdKeyCounter(did: PrismDID): RIO[WalletAccessContext, Option[HdKeyIndexCounter]] = { val status: ScheduledDIDOperationStatus = ScheduledDIDOperationStatus.Confirmed val cxnIO = diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/storage/DIDNonSecretStorage.scala b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/storage/DIDNonSecretStorage.scala index 1830dc1600..612338b1ad 100644 --- a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/storage/DIDNonSecretStorage.scala +++ b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/storage/DIDNonSecretStorage.scala @@ -21,6 +21,8 @@ trait DIDNonSecretStorage { def getMaxDIDIndex(): RIO[WalletAccessContext, Option[Int]] + def incrementAndGetNextDIDIndex: URIO[WalletAccessContext, Int] + def getHdKeyCounter(did: PrismDID): RIO[WalletAccessContext, Option[HdKeyIndexCounter]] /** Return a tuple of key metadata and the operation hash */ diff --git a/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/storage/MockDIDNonSecretStorage.scala b/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/storage/MockDIDNonSecretStorage.scala index f14df4d00d..6a29b2769d 100644 --- a/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/storage/MockDIDNonSecretStorage.scala +++ b/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/storage/MockDIDNonSecretStorage.scala @@ -49,6 +49,9 @@ case class MockDIDNonSecretStorage(proxy: Proxy) extends DIDNonSecretStorage { override def getMaxDIDIndex(): RIO[WalletAccessContext, Option[Int]] = proxy(MockDIDNonSecretStorage.GetMaxDIDIndex) + override def incrementAndGetNextDIDIndex: URIO[WalletAccessContext, RuntimeFlags] = + proxy(MockDIDNonSecretStorage.IncrementAndGetNextDIDIndex) + override def getHdKeyCounter(did: PrismDID): RIO[WalletAccessContext, Option[HdKeyIndexCounter]] = proxy(MockDIDNonSecretStorage.GetHdKeyCounter, did) @@ -89,6 +92,7 @@ object MockDIDNonSecretStorage extends Mock[DIDNonSecretStorage] { ] object UpdateManagedDID extends Effect[(PrismDID, ManagedDIDStatePatch), Throwable, Unit] object GetMaxDIDIndex extends Effect[Unit, Throwable, Option[Int]] + object IncrementAndGetNextDIDIndex extends Effect[Unit, Nothing, Int] object GetHdKeyCounter extends Effect[PrismDID, Throwable, Option[HdKeyIndexCounter]] object GetKeyMeta extends Effect[(PrismDID, KeyId), Throwable, Option[(ManagedDIDKeyMeta, Array[Byte])]] object InsertHdKeyMeta extends Effect[(PrismDID, KeyId, ManagedDIDKeyMeta, Array[Byte]), Throwable, Unit] diff --git a/connect/core/src/main/scala/org/hyperledger/identus/connect/core/model/WalletIdAndRecordId.scala b/connect/core/src/main/scala/org/hyperledger/identus/connect/core/model/WalletIdAndRecordId.scala new file mode 100644 index 0000000000..687f5e9aa1 --- /dev/null +++ b/connect/core/src/main/scala/org/hyperledger/identus/connect/core/model/WalletIdAndRecordId.scala @@ -0,0 +1,19 @@ +//package org.hyperledger.identus.connect.core.model +// +//import org.hyperledger.identus.messaging.Serde +//import zio.json.{DecoderOps, DeriveJsonDecoder, DeriveJsonEncoder, EncoderOps, JsonDecoder, JsonEncoder} +// +//import java.nio.charset.StandardCharsets +//import java.util.UUID +// +//case class WalletIdAndRecordId(walletId: UUID, recordId: UUID) +// +//object WalletIdAndRecordId { +// given encoder: JsonEncoder[WalletIdAndRecordId] = DeriveJsonEncoder.gen[WalletIdAndRecordId] +// given decoder: JsonDecoder[WalletIdAndRecordId] = DeriveJsonDecoder.gen[WalletIdAndRecordId] +// given ser: Serde[WalletIdAndRecordId] = new Serde[WalletIdAndRecordId] { +// override def serialize(t: WalletIdAndRecordId): Array[Byte] = t.toJson.getBytes(StandardCharsets.UTF_8) +// override def deserialize(ba: Array[Byte]): WalletIdAndRecordId = +// new String(ba, StandardCharsets.UTF_8).fromJson[WalletIdAndRecordId].getOrElse(throw RuntimeException("")) +// } +//} diff --git a/connect/core/src/main/scala/org/hyperledger/identus/connect/core/service/ConnectionServiceImpl.scala b/connect/core/src/main/scala/org/hyperledger/identus/connect/core/service/ConnectionServiceImpl.scala index a4072aea05..db91377abc 100644 --- a/connect/core/src/main/scala/org/hyperledger/identus/connect/core/service/ConnectionServiceImpl.scala +++ b/connect/core/src/main/scala/org/hyperledger/identus/connect/core/service/ConnectionServiceImpl.scala @@ -1,14 +1,13 @@ package org.hyperledger.identus.connect.core.service -import org.hyperledger.identus.* import org.hyperledger.identus.connect.core.model.{ConnectionRecord, ConnectionRecordBeforeStored} -import org.hyperledger.identus.connect.core.model.error.ConnectionServiceError import org.hyperledger.identus.connect.core.model.error.ConnectionServiceError.* import org.hyperledger.identus.connect.core.model.ConnectionRecord.* import org.hyperledger.identus.connect.core.repository.ConnectionRepository import org.hyperledger.identus.mercury.model.DidId import org.hyperledger.identus.mercury.protocol.connection.* import org.hyperledger.identus.mercury.protocol.invitation.v2.Invitation +import org.hyperledger.identus.shared.messaging.{Producer, WalletIdAndRecordId} import org.hyperledger.identus.shared.models.* import org.hyperledger.identus.shared.utils.aspects.CustomMetricsAspect import org.hyperledger.identus.shared.utils.Base64Utils @@ -21,9 +20,12 @@ import java.util.UUID private class ConnectionServiceImpl( connectionRepository: ConnectionRepository, + messageProducer: Producer[UUID, WalletIdAndRecordId], maxRetries: Int = 5, // TODO move to config ) extends ConnectionService { + private val TOPIC_NAME = "connect" + override def createConnectionInvitation( label: Option[String], goalCode: Option[String], @@ -147,6 +149,11 @@ private class ConnectionServiceImpl( @@ CustomMetricsAspect.startRecordingTime( s"${record.id}_invitee_pending_to_req_sent" ) + walletAccessContext <- ZIO.service[WalletAccessContext] + // TODO Should we use a singleton producer or create a new one each time?? (underlying Kafka Producer is thread safe) + _ <- messageProducer + .produce(TOPIC_NAME, record.id, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id)) + .orDie maybeRecord <- connectionRepository .findById(record.id) record <- ZIO.getOrFailWith(RecordIdNotFound(recordId))(maybeRecord) @@ -220,6 +227,10 @@ private class ConnectionServiceImpl( @@ CustomMetricsAspect.startRecordingTime( s"${record.id}_inviter_pending_to_res_sent" ) + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id)) + .orDie record <- connectionRepository.getById(record.id) } yield record @@ -306,6 +317,6 @@ private class ConnectionServiceImpl( } object ConnectionServiceImpl { - val layer: URLayer[ConnectionRepository, ConnectionService] = - ZLayer.fromFunction(ConnectionServiceImpl(_)) + val layer: URLayer[ConnectionRepository & Producer[UUID, WalletIdAndRecordId], ConnectionService] = + ZLayer.fromFunction(ConnectionServiceImpl(_, _)) } diff --git a/connect/core/src/test/scala/org/hyperledger/identus/connect/core/service/ConnectionServiceImplSpec.scala b/connect/core/src/test/scala/org/hyperledger/identus/connect/core/service/ConnectionServiceImplSpec.scala index b0fa8d43fd..7067b55bf6 100644 --- a/connect/core/src/test/scala/org/hyperledger/identus/connect/core/service/ConnectionServiceImplSpec.scala +++ b/connect/core/src/test/scala/org/hyperledger/identus/connect/core/service/ConnectionServiceImplSpec.scala @@ -3,17 +3,17 @@ package org.hyperledger.identus.connect.core.service import io.circe.syntax.* import org.hyperledger.identus.connect.core.model.error.ConnectionServiceError import org.hyperledger.identus.connect.core.model.error.ConnectionServiceError.InvalidStateForOperation -import org.hyperledger.identus.connect.core.model.ConnectionRecord import org.hyperledger.identus.connect.core.model.ConnectionRecord.* import org.hyperledger.identus.connect.core.repository.ConnectionRepositoryInMemory import org.hyperledger.identus.mercury.model.{DidId, Message} import org.hyperledger.identus.mercury.protocol.connection.ConnectionResponse +import org.hyperledger.identus.shared.messaging +import org.hyperledger.identus.shared.messaging.WalletIdAndRecordId import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import zio.* import zio.test.* import zio.test.Assertion.* -import java.time.Instant import java.util.UUID object ConnectionServiceImplSpec extends ZIOSpecDefault { @@ -310,7 +310,13 @@ object ConnectionServiceImplSpec extends ZIOSpecDefault { } } } - ).provide(connectionServiceLayer, ZLayer.succeed(WalletAccessContext(WalletId.random))) + ).provide( + connectionServiceLayer, + messaging.MessagingServiceConfig.inMemoryLayer, + messaging.MessagingService.serviceLayer, + messaging.MessagingService.producerLayer[UUID, WalletIdAndRecordId], + ZLayer.succeed(WalletAccessContext(WalletId.random)), + ) } } diff --git a/connect/core/src/test/scala/org/hyperledger/identus/connect/core/service/ConnectionServiceNotifierSpec.scala b/connect/core/src/test/scala/org/hyperledger/identus/connect/core/service/ConnectionServiceNotifierSpec.scala index b9e54811b9..185bd95b95 100644 --- a/connect/core/src/test/scala/org/hyperledger/identus/connect/core/service/ConnectionServiceNotifierSpec.scala +++ b/connect/core/src/test/scala/org/hyperledger/identus/connect/core/service/ConnectionServiceNotifierSpec.scala @@ -7,11 +7,12 @@ import org.hyperledger.identus.event.notification.* import org.hyperledger.identus.mercury.model.DidId import org.hyperledger.identus.mercury.protocol.connection.{ConnectionRequest, ConnectionResponse} import org.hyperledger.identus.mercury.protocol.invitation.v2.Invitation +import org.hyperledger.identus.shared.messaging +import org.hyperledger.identus.shared.messaging.WalletIdAndRecordId import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import zio.* import zio.mock.Expectation import zio.test.* -import zio.ZIO.* import java.time.Instant import java.util.UUID @@ -151,7 +152,10 @@ object ConnectionServiceNotifierSpec extends ZIOSpecDefault { ConnectionRepositoryInMemory.layer ++ inviteeExpectations.toLayer ) >>> ConnectionServiceNotifier.layer, - ZLayer.succeed(WalletAccessContext(WalletId.random)) + ZLayer.succeed(WalletAccessContext(WalletId.random)), + messaging.MessagingServiceConfig.inMemoryLayer, + messaging.MessagingService.serviceLayer, + messaging.MessagingService.producerLayer[UUID, WalletIdAndRecordId] ) ) } diff --git a/event-notification/src/test/scala/org/hyperledger/identus/messaging/MessagingServiceTest.scala b/event-notification/src/test/scala/org/hyperledger/identus/messaging/MessagingServiceTest.scala new file mode 100644 index 0000000000..54aea505f6 --- /dev/null +++ b/event-notification/src/test/scala/org/hyperledger/identus/messaging/MessagingServiceTest.scala @@ -0,0 +1,49 @@ +package org.hyperledger.identus.messaging + +import org.hyperledger.identus.shared.messaging +import org.hyperledger.identus.shared.messaging.{Message, MessagingService, Serde} +import zio.{durationInt, Random, Schedule, Scope, URIO, ZIO, ZIOAppArgs, ZIOAppDefault, ZLayer} +import zio.json.{DecoderOps, DeriveJsonDecoder, DeriveJsonEncoder, EncoderOps, JsonDecoder, JsonEncoder} + +import java.nio.charset.StandardCharsets +import java.util.UUID + +case class Customer(name: String) + +object Customer { + given encoder: JsonEncoder[Customer] = DeriveJsonEncoder.gen[Customer] + given decoder: JsonDecoder[Customer] = DeriveJsonDecoder.gen[Customer] + given serde: Serde[Customer] = new Serde[Customer]: + override def serialize(t: Customer): Array[Byte] = + t.toJson.getBytes(StandardCharsets.UTF_8) + override def deserialize(ba: Array[Byte]): Customer = + new String(ba, StandardCharsets.UTF_8).fromJson[Customer].getOrElse(Customer("Parsing Error")) +} + +object MessagingServiceTest extends ZIOAppDefault { + override def run: ZIO[Any with ZIOAppArgs with Scope, Any, Any] = { + val effect = for { + ms <- ZIO.service[MessagingService] + consumer <- ms.makeConsumer[UUID, Customer]("identus-cloud-agent") + producer <- ms.makeProducer[UUID, Customer]() + f1 <- consumer + .consume("Connect")(handle) + .fork + f2 <- Random.nextUUID + .flatMap(uuid => producer.produce("Connect", uuid, Customer(s"Name $uuid"))) + .repeat(Schedule.spaced(500.millis)) + .fork + _ <- ZIO.never + } yield () + effect.provide( + messaging.MessagingServiceConfig.inMemoryLayer, + messaging.MessagingService.serviceLayer, + ZLayer.succeed("Sample 'R' passed to handler") + ) + } + + def handle[K, V](msg: Message[K, V]): URIO[String, Unit] = for { + tag <- ZIO.service[String] + _ <- ZIO.logInfo(s"Handling new message [$tag]: ${msg.offset} - ${msg.key} - ${msg.value}") + } yield () +} diff --git a/event-notification/src/test/scala/org/hyperledger/identus/messaging/kafka/InMemoryMessagingServiceSpec.scala b/event-notification/src/test/scala/org/hyperledger/identus/messaging/kafka/InMemoryMessagingServiceSpec.scala new file mode 100644 index 0000000000..c6b068f16b --- /dev/null +++ b/event-notification/src/test/scala/org/hyperledger/identus/messaging/kafka/InMemoryMessagingServiceSpec.scala @@ -0,0 +1,66 @@ +package org.hyperledger.identus.messaging.kafka + +import org.hyperledger.identus.shared.messaging.* +import zio.* +import zio.test.* +import zio.test.Assertion.* + +object InMemoryMessagingServiceSpec extends ZIOSpecDefault { + val testLayer = MessagingServiceConfig.inMemoryLayer >+> MessagingService.serviceLayer >+> + MessagingService.producerLayer[String, String] >+> + MessagingService.consumerLayer[String, String]("test-group") + + def spec = suite("InMemoryMessagingServiceSpec")( + test("should produce and consume messages") { + + val key = "key" + val value = "value" + val topic = "test-topic" + for { + producer <- ZIO.service[Producer[String, String]] + consumer <- ZIO.service[Consumer[String, String]] + promise <- Promise.make[Nothing, Message[String, String]] + _ <- producer.produce(topic, key, value) + _ <- consumer + .consume(topic) { msg => + promise.succeed(msg).unit + } + .fork + receivedMessage <- promise.await + } yield assert(receivedMessage)(equalTo(Message(key, value, 1L, 0))) + }.provideLayer(testLayer), + test("should produce and consume 5 messages") { + val topic = "test-topic" + val messages = List( + ("key1", "value1"), + ("key2", "value2"), + ("key3", "value3"), + ("key4", "value4"), + ("key5", "value5") + ) + + for { + producer <- ZIO.service[Producer[String, String]] + consumer <- ZIO.service[Consumer[String, String]] + promise <- Promise.make[Nothing, List[Message[String, String]]] + ref <- Ref.make(List.empty[Message[String, String]]) + + _ <- ZIO.foreach(messages) { case (key, value) => + producer.produce(topic, key, value) *> ZIO.debug(s"Produced message: $key -> $value") + } + _ <- consumer + .consume(topic) { msg => + ZIO.debug(s"Consumed message: ${msg.key} -> ${msg.value}") *> + ref.update(_ :+ msg) *> ref.get.flatMap { msgs => + if (msgs.size == messages.size) promise.succeed(msgs).unit else ZIO.unit + } + } + .fork + receivedMessages <- promise.await + _ <- ZIO.debug(s"Received messages: ${receivedMessages.map(m => (m.key, m.value))}") + } yield assert(receivedMessages.map(m => (m.key, m.value)).sorted)( + equalTo(messages.sorted) + ) + }.provideLayer(testLayer), + ) +} diff --git a/infrastructure/shared/docker-compose-with-kafka.yml b/infrastructure/shared/docker-compose-with-kafka.yml new file mode 100644 index 0000000000..3e24ad6128 --- /dev/null +++ b/infrastructure/shared/docker-compose-with-kafka.yml @@ -0,0 +1,256 @@ +--- +services: + ########################## + # Database + ########################## + db: + image: postgres:13 + environment: + POSTGRES_MULTIPLE_DATABASES: "pollux,connect,agent,node_db" + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - pg_data_db:/var/lib/postgresql/data + - ./postgres/init-script.sh:/docker-entrypoint-initdb.d/init-script.sh + - ./postgres/max_conns.sql:/docker-entrypoint-initdb.d/max_conns.sql + ports: + - "127.0.0.1:${PG_PORT:-5432}:5432" + healthcheck: + test: ["CMD", "pg_isready", "-U", "postgres", "-d", "agent"] + + interval: 10s + timeout: 5s + retries: 5 + + pgadmin: + image: dpage/pgadmin4 + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-pgadmin4@pgadmin.org} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} + PGADMIN_CONFIG_SERVER_MODE: "False" + volumes: + - pgadmin:/var/lib/pgadmin + ports: + - "127.0.0.1:${PGADMIN_PORT:-5050}:80" + depends_on: + db: + condition: service_healthy + profiles: + - debug + + ########################## + # Services + ########################## + + prism-node: + image: ghcr.io/input-output-hk/prism-node:${PRISM_NODE_VERSION} + environment: + NODE_PSQL_HOST: db:5432 + NODE_REFRESH_AND_SUBMIT_PERIOD: + NODE_MOVE_SCHEDULED_TO_PENDING_PERIOD: + NODE_WALLET_MAX_TPS: + depends_on: + db: + condition: service_healthy + + vault-server: + image: hashicorp/vault:latest + # ports: + # - "8200:8200" + environment: + VAULT_ADDR: "http://0.0.0.0:8200" + VAULT_DEV_ROOT_TOKEN_ID: ${VAULT_DEV_ROOT_TOKEN_ID} + command: server -dev -dev-root-token-id=${VAULT_DEV_ROOT_TOKEN_ID} + cap_add: + - IPC_LOCK + healthcheck: + test: ["CMD", "vault", "status"] + interval: 10s + timeout: 5s + retries: 5 + + cloud-agent: + image: ghcr.io/hyperledger/identus-cloud-agent:${AGENT_VERSION} + environment: + POLLUX_DB_HOST: db + POLLUX_DB_PORT: 5432 + POLLUX_DB_NAME: pollux + POLLUX_DB_USER: postgres + POLLUX_DB_PASSWORD: postgres + CONNECT_DB_HOST: db + CONNECT_DB_PORT: 5432 + CONNECT_DB_NAME: connect + CONNECT_DB_USER: postgres + CONNECT_DB_PASSWORD: postgres + AGENT_DB_HOST: db + AGENT_DB_PORT: 5432 + AGENT_DB_NAME: agent + AGENT_DB_USER: postgres + AGENT_DB_PASSWORD: postgres + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://${DOCKERHOST}:${PORT}/cloud-agent + DIDCOMM_SERVICE_URL: http://${DOCKERHOST}:${PORT}/didcomm + REST_SERVICE_URL: http://${DOCKERHOST}:${PORT}/cloud-agent + PRISM_NODE_HOST: prism-node + PRISM_NODE_PORT: 50053 + VAULT_ADDR: ${VAULT_ADDR:-http://vault-server:8200} + VAULT_TOKEN: ${VAULT_DEV_ROOT_TOKEN_ID:-root} + SECRET_STORAGE_BACKEND: postgres + DEV_MODE: true + DEFAULT_WALLET_ENABLED: + DEFAULT_WALLET_SEED: + DEFAULT_WALLET_WEBHOOK_URL: + DEFAULT_WALLET_WEBHOOK_API_KEY: + DEFAULT_WALLET_AUTH_API_KEY: + DEFAULT_KAFKA_ENABLED: true + GLOBAL_WEBHOOK_URL: + GLOBAL_WEBHOOK_API_KEY: + WEBHOOK_PARALLELISM: + ADMIN_TOKEN: + API_KEY_SALT: + API_KEY_ENABLED: + API_KEY_AUTHENTICATE_AS_DEFAULT_USER: + API_KEY_AUTO_PROVISIONING: + depends_on: + db: + condition: service_healthy + prism-node: + condition: service_started + vault-server: + condition: service_healthy + init-kafka: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://cloud-agent:8085/_system/health"] + interval: 30s + timeout: 10s + retries: 5 + extra_hosts: + - "host.docker.internal:host-gateway" + + swagger-ui: + image: swaggerapi/swagger-ui:v5.1.0 + environment: + - 'URLS=[ + { name: "Cloud Agent", url: "/docs/cloud-agent/api/docs.yaml" } + ]' + + # apisix: + # image: apache/apisix:2.15.0-alpine + # volumes: + # - ./apisix/conf/apisix.yaml:/usr/local/apisix/conf/apisix.yaml:ro + # - ./apisix/conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro + # ports: + # - "${PORT}:9080/tcp" + # depends_on: + # - cloud-agent + # - swagger-ui + + nginx: + image: nginx:latest + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + ports: + - "${PORT}:80/tcp" + depends_on: + - cloud-agent + - swagger-ui + + zookeeper: + image: confluentinc/cp-zookeeper:latest + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + # ports: + # - 22181:2181 + + kafka: + image: confluentinc/cp-kafka:latest + depends_on: + - zookeeper + # ports: + # - 29092:29092 + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: false + healthcheck: + test: + [ + "CMD", + "kafka-topics", + "--list", + "--bootstrap-server", + "localhost:9092", + ] + interval: 5s + timeout: 10s + retries: 5 + + init-kafka: + image: confluentinc/cp-kafka:latest + depends_on: + kafka: + condition: service_healthy + entrypoint: ["/bin/sh", "-c"] + command: | + " + # blocks until kafka is reachable + kafka-topics --bootstrap-server kafka:9092 --list + echo -e 'Creating kafka topics' + + # Connect + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic connect --replication-factor 1 --partitions 20 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic connect-retry-1 --replication-factor 1 --partitions 20 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic connect-retry-2 --replication-factor 1 --partitions 20 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic connect-retry-3 --replication-factor 1 --partitions 20 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic connect-retry-4 --replication-factor 1 --partitions 20 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic connect-DLQ --replication-factor 1 --partitions 1 + + # Issue + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic issue --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic issue-retry-1 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic issue-retry-2 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic issue-retry-3 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic issue-retry-4 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic issue-DLQ --replication-factor 1 --partitions 1 + + # Present + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic present --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic present-retry-1 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic present-retry-2 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic present-retry-3 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic present-retry-4 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic present-DLQ --replication-factor 1 --partitions 1 + + # DID Publication State Sync + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic sync-did-state --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic sync-did-state-DLQ --replication-factor 1 --partitions 5 + + # Status List Sync + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic sync-status-list --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic sync-status-list-DLQ --replication-factor 1 --partitions 5 + + tail -f /dev/null + " + healthcheck: + test: + [ + "CMD-SHELL", + "kafka-topics --bootstrap-server kafka:9092 --list | grep -q 'sync-status-list'", + ] + interval: 5s + timeout: 10s + retries: 5 + +volumes: + pg_data_db: + pgadmin: +# Temporary commit network setting due to e2e CI bug +# to be enabled later after debugging +#networks: +# default: +# name: ${NETWORK} diff --git a/infrastructure/shared/nginx/nginx.conf b/infrastructure/shared/nginx/nginx.conf new file mode 100644 index 0000000000..937dc35a3f --- /dev/null +++ b/infrastructure/shared/nginx/nginx.conf @@ -0,0 +1,42 @@ +user nginx; + +events { + worker_connections 1000; +} + +http { + # Docker embedded DNS server (overriding TTL) + resolver 127.0.0.11 valid=5s; + + # Upstreams + upstream cloud_agent_8090 { + server cloud-agent:8090; + } + + upstream cloud_agent_8085 { + server cloud-agent:8085; + } + + # Server configuration + server { + listen 80; + + # Route /cloud-agent/* + location ~ ^/cloud-agent/(.*) { + # Proxy rewrite + set $upstream_servers cloud-agent; + rewrite ^/cloud-agent/(.*) /$1 break; + proxy_pass http://$upstream_servers:8085; + proxy_connect_timeout 5s; + } + + # Route /didcomm* + location ~ ^/didcomm(.*) { + # Proxy rewrite + set $upstream_servers cloud-agent; + rewrite ^/didcomm(.*) /$1 break; + proxy_pass http://$upstream_servers:8090; + proxy_connect_timeout 5s; + } + } +} \ No newline at end of file diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/DidCommID.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/DidCommID.scala index 47fcced892..ed18017d45 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/DidCommID.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/DidCommID.scala @@ -7,4 +7,6 @@ opaque type DidCommID = String object DidCommID: def apply(value: String): DidCommID = value def apply(): DidCommID = UUID.randomUUID.toString() - extension (id: DidCommID) def value: String = id + extension (id: DidCommID) + def value: String = id + def uuid: UUID = UUID.fromString(id) diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/CredentialStatusListRepository.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/CredentialStatusListRepository.scala index 6a86509592..6d8cdbb50d 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/CredentialStatusListRepository.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/CredentialStatusListRepository.scala @@ -1,30 +1,61 @@ package org.hyperledger.identus.pollux.core.repository import org.hyperledger.identus.pollux.core.model.* +import org.hyperledger.identus.pollux.vc.jwt.revocation.{BitString, VCStatusList2021} +import org.hyperledger.identus.pollux.vc.jwt.revocation.BitStringError.{ + DecodingError, + EncodingError, + IndexOutOfBounds, + InvalidSize +} import org.hyperledger.identus.pollux.vc.jwt.Issuer -import org.hyperledger.identus.shared.models.WalletAccessContext +import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import zio.* import java.util.UUID trait CredentialStatusListRepository { - def getCredentialStatusListsWithCreds: UIO[List[CredentialStatusListWithCreds]] + def createStatusListVC( + jwtIssuer: Issuer, + statusListRegistryUrl: String, + id: UUID + ): IO[Throwable, String] = { + for { + bitString <- BitString.getInstance().mapError { + case InvalidSize(message) => new Throwable(message) + case EncodingError(message) => new Throwable(message) + case DecodingError(message) => new Throwable(message) + case IndexOutOfBounds(message) => new Throwable(message) + } + emptyStatusListCredential <- VCStatusList2021 + .build( + vcId = s"$statusListRegistryUrl/credential-status/$id", + revocationData = bitString, + jwtIssuer = jwtIssuer + ) + .mapError(x => new Throwable(x.msg)) + + credentialWithEmbeddedProof <- emptyStatusListCredential.toJsonWithEmbeddedProof + } yield credentialWithEmbeddedProof.spaces2 + } + + def getCredentialStatusListIds: UIO[Seq[(WalletId, UUID)]] + + def getCredentialStatusListsWithCreds(statusListId: UUID): URIO[WalletAccessContext, CredentialStatusListWithCreds] def findById( id: UUID ): UIO[Option[CredentialStatusList]] - def getLatestOfTheWallet: URIO[WalletAccessContext, Option[CredentialStatusList]] + def incrementAndGetStatusListIndex( + jwtIssuer: Issuer, + statusListRegistryUrl: String + ): URIO[WalletAccessContext, (UUID, Int)] def existsForIssueCredentialRecordId( id: DidCommID ): URIO[WalletAccessContext, Boolean] - def createNewForTheWallet( - jwtIssuer: Issuer, - statusListRegistryServiceName: String - ): URIO[WalletAccessContext, CredentialStatusList] - def allocateSpaceForCredential( issueCredentialRecordId: DidCommID, credentialStatusListId: UUID, diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceImpl.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceImpl.scala index 4e059f8e05..a95e80925e 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceImpl.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceImpl.scala @@ -28,6 +28,7 @@ import org.hyperledger.identus.pollux.sdjwt.* import org.hyperledger.identus.pollux.vc.jwt.{Issuer as JwtIssuer, *} import org.hyperledger.identus.shared.crypto.{Ed25519KeyPair, Secp256k1KeyPair} import org.hyperledger.identus.shared.http.UriResolver +import org.hyperledger.identus.shared.messaging.{Producer, WalletIdAndRecordId} import org.hyperledger.identus.shared.models.* import org.hyperledger.identus.shared.utils.aspects.CustomMetricsAspect import org.hyperledger.identus.shared.utils.Base64Utils @@ -42,7 +43,8 @@ import scala.language.implicitConversions object CredentialServiceImpl { val layer: URLayer[ CredentialRepository & CredentialStatusListRepository & DidResolver & UriResolver & GenericSecretStorage & - CredentialDefinitionService & LinkSecretService & DIDService & ManagedDIDService, + CredentialDefinitionService & LinkSecretService & DIDService & ManagedDIDService & + Producer[UUID, WalletIdAndRecordId], CredentialService ] = { ZLayer.fromZIO { @@ -56,7 +58,7 @@ object CredentialServiceImpl { linkSecretService <- ZIO.service[LinkSecretService] didService <- ZIO.service[DIDService] manageDidService <- ZIO.service[ManagedDIDService] - issueCredentialSem <- Semaphore.make(1) + messageProducer <- ZIO.service[Producer[UUID, WalletIdAndRecordId]] } yield CredentialServiceImpl( credentialRepo, credentialStatusListRepo, @@ -68,7 +70,7 @@ object CredentialServiceImpl { didService, manageDidService, 5, - issueCredentialSem + messageProducer ) } } @@ -88,12 +90,14 @@ class CredentialServiceImpl( didService: DIDService, managedDIDService: ManagedDIDService, maxRetries: Int = 5, // TODO move to config - issueCredentialSem: Semaphore + messageProducer: Producer[UUID, WalletIdAndRecordId], ) extends CredentialService { import CredentialServiceImpl.* import IssueCredentialRecord.* + private val TOPIC_NAME = "issue" + override def getIssueCredentialRecords( ignoreWithZeroRetries: Boolean, offset: Option[Int], @@ -187,6 +191,10 @@ class CredentialServiceImpl( count <- credentialRepository .create(record) @@ CustomMetricsAspect .startRecordingTime(s"${record.id}_issuer_offer_pending_to_sent_ms_gauge") + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie } yield record } @@ -500,6 +508,10 @@ class CredentialServiceImpl( ) case (format, maybeSubjectId) => ZIO.dieMessage(s"Invalid subjectId input for $format offer acceptance: $maybeSubjectId") + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie record <- credentialRepository.getById(record.id) } yield record } @@ -661,6 +673,10 @@ class CredentialServiceImpl( s"${record.id}_issuance_flow_holder_req_pending_to_generated", "issuance_flow_holder_req_pending_to_generated_ms_gauge" ) @@ CustomMetricsAspect.startRecordingTime(s"${record.id}_issuance_flow_holder_req_generated_to_sent") + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie record <- credentialRepository.getById(record.id) } yield record } @@ -707,6 +723,10 @@ class CredentialServiceImpl( s"${record.id}_issuance_flow_holder_req_pending_to_generated", "issuance_flow_holder_req_pending_to_generated_ms_gauge" ) @@ CustomMetricsAspect.startRecordingTime(s"${record.id}_issuance_flow_holder_req_generated_to_sent") + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie record <- credentialRepository.getById(record.id) } yield record } @@ -751,6 +771,10 @@ class CredentialServiceImpl( ProtocolState.OfferSent ) _ <- credentialRepository.updateWithJWTRequestCredential(record.id, request, ProtocolState.RequestReceived) + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie record <- credentialRepository.getById(record.id) } yield record } @@ -769,6 +793,10 @@ class CredentialServiceImpl( @@ CustomMetricsAspect.startRecordingTime( s"${record.id}_issuance_flow_issuer_credential_pending_to_generated" ) + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie record <- credentialRepository.getById(record.id) } yield record } @@ -913,6 +941,10 @@ class CredentialServiceImpl( s"${record.id}_issuance_flow_issuer_credential_pending_to_generated", "issuance_flow_issuer_credential_pending_to_generated_ms_gauge" ) @@ CustomMetricsAspect.startRecordingTime(s"${record.id}_issuance_flow_issuer_credential_generated_to_sent") + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie record <- credentialRepository.getById(record.id) } yield record } @@ -1275,32 +1307,26 @@ class CredentialServiceImpl( record: IssueCredentialRecord, statusListRegistryUrl: String, jwtIssuer: JwtIssuer - ): URIO[WalletAccessContext, CredentialStatus] = { - val effect = for { - lastStatusList <- credentialStatusListRepository.getLatestOfTheWallet - currentStatusList <- lastStatusList - .fold(credentialStatusListRepository.createNewForTheWallet(jwtIssuer, statusListRegistryUrl))( - ZIO.succeed(_) - ) - size = currentStatusList.size - lastUsedIndex = currentStatusList.lastUsedIndex - statusListToBeUsed <- - if lastUsedIndex < size then ZIO.succeed(currentStatusList) - else credentialStatusListRepository.createNewForTheWallet(jwtIssuer, statusListRegistryUrl) + ): URIO[WalletAccessContext, CredentialStatus] = + for { + cslAndIndex <- credentialStatusListRepository.incrementAndGetStatusListIndex( + jwtIssuer, + statusListRegistryUrl + ) + statusListId = cslAndIndex._1 + indexInStatusList = cslAndIndex._2 _ <- credentialStatusListRepository.allocateSpaceForCredential( issueCredentialRecordId = record.id, - credentialStatusListId = statusListToBeUsed.id, - statusListIndex = statusListToBeUsed.lastUsedIndex + 1 + credentialStatusListId = statusListId, + statusListIndex = indexInStatusList ) } yield CredentialStatus( - id = s"$statusListRegistryUrl/credential-status/${statusListToBeUsed.id}#${statusListToBeUsed.lastUsedIndex + 1}", + id = s"$statusListRegistryUrl/credential-status/$statusListId#$indexInStatusList", `type` = "StatusList2021Entry", statusPurpose = StatusPurpose.Revocation, - statusListIndex = lastUsedIndex + 1, - statusListCredential = s"$statusListRegistryUrl/credential-status/${statusListToBeUsed.id}" + statusListIndex = indexInStatusList, + statusListCredential = s"$statusListRegistryUrl/credential-status/$statusListId" ) - issueCredentialSem.withPermit(effect) - } override def generateAnonCredsCredential( recordId: DidCommID diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialStatusListService.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialStatusListService.scala index 5a186d2826..418b3faa0c 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialStatusListService.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialStatusListService.scala @@ -6,7 +6,7 @@ import org.hyperledger.identus.pollux.core.model.error.CredentialStatusListServi StatusListNotFound, StatusListNotFoundForIssueCredentialRecord } -import org.hyperledger.identus.shared.models.WalletAccessContext +import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import zio.* import java.util.UUID @@ -20,7 +20,9 @@ trait CredentialStatusListService { id: DidCommID ): ZIO[WalletAccessContext, StatusListNotFoundForIssueCredentialRecord | InvalidRoleForOperation, Unit] - def getCredentialsAndItsStatuses: UIO[Seq[CredentialStatusListWithCreds]] + def getCredentialStatusListIds: UIO[Seq[(WalletId, UUID)]] + + def getCredentialStatusListWithCreds(statusListId: UUID): URIO[WalletAccessContext, CredentialStatusListWithCreds] def updateStatusListCredential( id: UUID, diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialStatusListServiceImpl.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialStatusListServiceImpl.scala index 92565a8559..ef752f8648 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialStatusListServiceImpl.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialStatusListServiceImpl.scala @@ -8,7 +8,7 @@ import org.hyperledger.identus.pollux.core.model.error.CredentialStatusListServi } import org.hyperledger.identus.pollux.core.model.IssueCredentialRecord.Role import org.hyperledger.identus.pollux.core.repository.CredentialStatusListRepository -import org.hyperledger.identus.shared.models.WalletAccessContext +import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import zio.* import java.util.UUID @@ -18,8 +18,11 @@ class CredentialStatusListServiceImpl( credentialStatusListRepository: CredentialStatusListRepository, ) extends CredentialStatusListService { - def getCredentialsAndItsStatuses: UIO[Seq[CredentialStatusListWithCreds]] = - credentialStatusListRepository.getCredentialStatusListsWithCreds + def getCredentialStatusListIds: UIO[Seq[(WalletId, UUID)]] = + credentialStatusListRepository.getCredentialStatusListIds + + def getCredentialStatusListWithCreds(statusListId: UUID): URIO[WalletAccessContext, CredentialStatusListWithCreds] = + credentialStatusListRepository.getCredentialStatusListsWithCreds(statusListId) def getById(id: UUID): IO[StatusListNotFound, CredentialStatusList] = for { diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationService.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationService.scala index 94aa1af79b..20426ec477 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationService.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationService.scala @@ -105,7 +105,7 @@ trait PresentationService { def findPresentationRecord( recordId: DidCommID - ): ZIO[WalletAccessContext, PresentationError, Option[PresentationRecord]] + ): URIO[WalletAccessContext, Option[PresentationRecord]] def findPresentationRecordByThreadId( thid: DidCommID diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceImpl.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceImpl.scala index 8200595ebf..3d18a25bbe 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceImpl.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceImpl.scala @@ -20,6 +20,7 @@ import org.hyperledger.identus.pollux.core.service.serdes.* import org.hyperledger.identus.pollux.sdjwt.{CredentialCompact, HolderPrivateKey, PresentationCompact, SDJWT} import org.hyperledger.identus.pollux.vc.jwt.* import org.hyperledger.identus.shared.http.UriResolver +import org.hyperledger.identus.shared.messaging.{Producer, WalletIdAndRecordId} import org.hyperledger.identus.shared.models.* import org.hyperledger.identus.shared.utils.aspects.CustomMetricsAspect import org.hyperledger.identus.shared.utils.Base64Utils @@ -37,11 +38,14 @@ private class PresentationServiceImpl( linkSecretService: LinkSecretService, presentationRepository: PresentationRepository, credentialRepository: CredentialRepository, - maxRetries: Int = 5, // TODO move to config + messageProducer: Producer[UUID, WalletIdAndRecordId], + maxRetries: Int = 5, // TODO move to config, ) extends PresentationService { import PresentationRecord.* + private val TOPIC_NAME = "present" + override def markPresentationGenerated( recordId: DidCommID, presentation: Presentation @@ -57,6 +61,10 @@ private class PresentationServiceImpl( ) @@ CustomMetricsAspect.startRecordingTime( s"${record.id}_present_proof_flow_prover_presentation_generated_to_sent_ms_gauge" ) + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie record <- getRecord(recordId) } yield record } @@ -298,7 +306,7 @@ private class PresentationServiceImpl( override def findPresentationRecord( recordId: DidCommID - ): ZIO[WalletAccessContext, PresentationError, Option[PresentationRecord]] = + ): URIO[WalletAccessContext, Option[PresentationRecord]] = presentationRepository.findPresentationRecord(recordId) override def findPresentationRecordByThreadId( @@ -459,6 +467,10 @@ private class PresentationServiceImpl( @@ CustomMetricsAspect.startRecordingTime( s"${record.id}_present_proof_flow_verifier_req_pending_to_sent_ms_gauge" ) + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie } yield record } @@ -531,6 +543,10 @@ private class PresentationServiceImpl( ) _ <- presentationRepository.createPresentationRecord(record) _ <- ZIO.logDebug(s"Received and created the RequestPresentation: $request") + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie } yield record } @@ -813,6 +829,10 @@ private class PresentationServiceImpl( @@ CustomMetricsAspect.startRecordingTime( s"${record.id}_present_proof_flow_prover_presentation_pending_to_generated_ms_gauge" ) + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie record <- getRecord(recordId) } yield record } @@ -841,6 +861,10 @@ private class PresentationServiceImpl( ) @@ CustomMetricsAspect.startRecordingTime( s"${record.id}_present_proof_flow_prover_presentation_pending_to_generated_ms_gauge" ) + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie record <- getRecord(recordId) } yield record } @@ -875,6 +899,10 @@ private class PresentationServiceImpl( ) @@ CustomMetricsAspect.startRecordingTime( s"${record.id}_present_proof_flow_prover_presentation_pending_to_generated_ms_gauge" ) + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie record <- getRecord(record.id) } yield record } @@ -961,6 +989,10 @@ private class PresentationServiceImpl( .startRecordingTime( s"${record.id}_present_proof_flow_verifier_presentation_received_to_verification_success_or_failure_ms_gauge" ) + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie record <- getRecord(record.id) } yield record } @@ -977,6 +1009,10 @@ private class PresentationServiceImpl( requestPresentation = createDidCommRequestPresentationFromProposal(request) _ <- presentationRepository .updateWithRequestPresentation(recordId, requestPresentation, ProtocolState.PresentationPending) + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie record <- getRecord(recordId) } yield record } @@ -993,6 +1029,10 @@ private class PresentationServiceImpl( record <- getRecordFromThreadId(thid) _ <- presentationRepository .updateWithProposePresentation(record.id, proposePresentation, ProtocolState.ProposalReceived) + walletAccessContext <- ZIO.service[WalletAccessContext] + _ <- messageProducer + .produce(TOPIC_NAME, record.id.uuid, WalletIdAndRecordId(walletAccessContext.walletId.toUUID, record.id.uuid)) + .orDie record <- getRecord(record.id) } yield record } @@ -1305,8 +1345,9 @@ private class PresentationServiceImpl( object PresentationServiceImpl { val layer: URLayer[ - UriResolver & LinkSecretService & PresentationRepository & CredentialRepository, + UriResolver & LinkSecretService & PresentationRepository & CredentialRepository & + Producer[UUID, WalletIdAndRecordId], PresentationService ] = - ZLayer.fromFunction(PresentationServiceImpl(_, _, _, _)) + ZLayer.fromFunction(PresentationServiceImpl(_, _, _, _, _)) } diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifier.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifier.scala index 80d358cdbf..350c161100 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifier.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifier.scala @@ -14,6 +14,7 @@ import org.hyperledger.identus.pollux.vc.jwt.{Issuer, PresentationPayload, W3cCr import org.hyperledger.identus.shared.models.* import zio.* import zio.json.* +import zio.URIO import java.time.Instant import java.util.UUID @@ -275,7 +276,7 @@ class PresentationServiceNotifier( override def findPresentationRecord( recordId: DidCommID - ): ZIO[WalletAccessContext, PresentationError, Option[PresentationRecord]] = + ): URIO[WalletAccessContext, Option[PresentationRecord]] = svc.findPresentationRecord(recordId) override def findPresentationRecordByThreadId( diff --git a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/CredentialStatusListRepositoryInMemory.scala b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/CredentialStatusListRepositoryInMemory.scala index 8ea5ca21d1..3e4f885f2b 100644 --- a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/CredentialStatusListRepositoryInMemory.scala +++ b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/CredentialStatusListRepositoryInMemory.scala @@ -1,15 +1,9 @@ package org.hyperledger.identus.pollux.core.repository -import org.hyperledger.identus.castor.core.model.did.{CanonicalPrismDID, PrismDID} +import org.hyperledger.identus.castor.core.model.did.PrismDID import org.hyperledger.identus.pollux.core.model.* -import org.hyperledger.identus.pollux.vc.jwt.{revocation, Issuer, StatusPurpose} -import org.hyperledger.identus.pollux.vc.jwt.revocation.{BitString, VCStatusList2021} -import org.hyperledger.identus.pollux.vc.jwt.revocation.BitStringError.{ - DecodingError, - EncodingError, - IndexOutOfBounds, - InvalidSize -} +import org.hyperledger.identus.pollux.vc.jwt.{Issuer, StatusPurpose} +import org.hyperledger.identus.pollux.vc.jwt.revocation.BitString import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import zio.* @@ -73,65 +67,69 @@ class CredentialStatusListRepositoryInMemory( exists = stores.flatMap(_.values).exists(_.issueCredentialRecordId == id) } yield exists - def getLatestOfTheWallet: URIO[WalletAccessContext, Option[CredentialStatusList]] = for { - storageRef <- walletToStatusListStorageRefs - storage <- storageRef.get - latest = storage.toSeq - .sortBy(_._2.createdAt) { (x, y) => if x.isAfter(y) then -1 else 1 /* DESC */ } - .headOption - .map(_._2) - } yield latest - - def createNewForTheWallet( + override def incrementAndGetStatusListIndex( jwtIssuer: Issuer, statusListRegistryUrl: String - ): URIO[WalletAccessContext, CredentialStatusList] = { + ): URIO[WalletAccessContext, (UUID, Int)] = + def getLatestOfTheWallet: URIO[WalletAccessContext, Option[CredentialStatusList]] = for { + storageRef <- walletToStatusListStorageRefs + storage <- storageRef.get + latest = storage.toSeq + .sortBy(_._2.createdAt) { (x, y) => if x.isAfter(y) then -1 else 1 /* DESC */ } + .headOption + .map(_._2) + } yield latest - val id = UUID.randomUUID() - val issued = Instant.now() - val issuerDid = jwtIssuer.did - val canonical = PrismDID.fromString(issuerDid.toString).fold(e => throw RuntimeException(e), _.asCanonical) + def createNewForTheWallet( + id: UUID, + jwtIssuer: Issuer, + issued: Instant, + credentialStr: String + ): URIO[WalletAccessContext, CredentialStatusList] = { + val issuerDid = jwtIssuer.did + val canonical = PrismDID.fromString(issuerDid.toString).fold(e => throw RuntimeException(e), _.asCanonical) - val embeddedProofCredential = for { - bitString <- BitString.getInstance().mapError { - case InvalidSize(message) => new Throwable(message) - case EncodingError(message) => new Throwable(message) - case DecodingError(message) => new Throwable(message) - case IndexOutOfBounds(message) => new Throwable(message) - } - resourcePath = - s"credential-status/$id" - emptyJwtCredential <- VCStatusList2021 - .build( - vcId = s"$statusListRegistryUrl/credential-status/$id", - revocationData = bitString, - jwtIssuer = jwtIssuer + for { + storageRef <- walletToStatusListStorageRefs + walletId <- ZIO.serviceWith[WalletAccessContext](_.walletId) + newCredentialStatusList = CredentialStatusList( + id = id, + walletId = walletId, + issuer = canonical, + issued = issued, + purpose = StatusPurpose.Revocation, + statusListCredential = credentialStr, + size = BitString.MIN_SL2021_SIZE, + lastUsedIndex = 0, + createdAt = Instant.now(), + updatedAt = None ) - .mapError(x => new Throwable(x.msg)) + _ <- storageRef.update(r => r + (newCredentialStatusList.id -> newCredentialStatusList)) + } yield newCredentialStatusList + } - credentialWithEmbeddedProof <- emptyJwtCredential.toJsonWithEmbeddedProof - } yield credentialWithEmbeddedProof.spaces2 + def updateLastUsedIndex(statusListId: UUID, lastUsedIndex: Int) = + for { + walletToStatusListStorageRef <- walletToStatusListStorageRefs + _ <- walletToStatusListStorageRef.update(r => { + val value = r.get(statusListId) + value.fold(r) { v => + val updated = v.copy(lastUsedIndex = lastUsedIndex, updatedAt = Some(Instant.now)) + r.updated(statusListId, updated) + } + }) + } yield () for { - credential <- embeddedProofCredential.orDie - storageRef <- walletToStatusListStorageRefs - walletId <- ZIO.serviceWith[WalletAccessContext](_.walletId) - newCredentialStatusList = CredentialStatusList( - id = id, - walletId = walletId, - issuer = canonical, - issued = issued, - purpose = StatusPurpose.Revocation, - statusListCredential = credential, - size = BitString.MIN_SL2021_SIZE, - lastUsedIndex = 0, - createdAt = Instant.now(), - updatedAt = None - ) - _ <- storageRef.update(r => r + (newCredentialStatusList.id -> newCredentialStatusList)) - } yield newCredentialStatusList - - } + id <- ZIO.succeed(UUID.randomUUID()) + newStatusListVC <- createStatusListVC(jwtIssuer, statusListRegistryUrl, id).orDie + maybeStatusList <- getLatestOfTheWallet + statusList <- maybeStatusList match + case Some(csl) if csl.lastUsedIndex < csl.size => ZIO.succeed(csl) + case _ => createNewForTheWallet(id, jwtIssuer, Instant.now(), newStatusListVC) + newIndex = statusList.lastUsedIndex + 1 + _ <- updateLastUsedIndex(statusList.id, newIndex) + } yield (statusList.id, newIndex) def allocateSpaceForCredential( issueCredentialRecordId: DidCommID, @@ -152,14 +150,6 @@ class CredentialStatusListRepositoryInMemory( for { credentialInStatusListStorageRef <- statusListToCredInStatusListStorageRefs(credentialStatusListId) _ <- credentialInStatusListStorageRef.update(r => r + (newCredentialInStatusList.id -> newCredentialInStatusList)) - walletToStatusListStorageRef <- walletToStatusListStorageRefs - _ <- walletToStatusListStorageRef.update(r => { - val value = r.get(credentialStatusListId) - value.fold(r) { v => - val updated = v.copy(lastUsedIndex = statusListIndex, updatedAt = Some(Instant.now)) - r.updated(credentialStatusListId, updated) - } - }) } yield () } @@ -188,37 +178,39 @@ class CredentialStatusListRepositoryInMemory( } yield () } - def getCredentialStatusListsWithCreds: UIO[List[CredentialStatusListWithCreds]] = { + override def getCredentialStatusListIds: UIO[Seq[(WalletId, UUID)]] = for { statusListsRefs <- allStatusListsStorageRefs statusLists <- statusListsRefs.get - statusListWithCredEffects = statusLists.map { (id, statusList) => - val credsinStatusListEffect = statusListToCredInStatusListStorageRefs(id).flatMap(_.get.map(_.values.toList)) - credsinStatusListEffect.map { credsInStatusList => - CredentialStatusListWithCreds( - id = id, - walletId = statusList.walletId, - issuer = statusList.issuer, - issued = statusList.issued, - purpose = statusList.purpose, - statusListCredential = statusList.statusListCredential, - size = statusList.size, - lastUsedIndex = statusList.lastUsedIndex, - credentials = credsInStatusList.map { cred => - CredInStatusList( - id = cred.id, - issueCredentialRecordId = cred.issueCredentialRecordId, - statusListIndex = cred.statusListIndex, - isCanceled = cred.isCanceled, - isProcessed = cred.isProcessed, - ) - } - ) - } + } yield statusLists.values.toList.map(csl => (csl.walletId, csl.id)) - }.toList - res <- ZIO.collectAll(statusListWithCredEffects) - } yield res + def getCredentialStatusListsWithCreds( + statusListId: UUID + ): URIO[WalletAccessContext, CredentialStatusListWithCreds] = { + for { + statusListsRefs <- allStatusListsStorageRefs + statusLists <- statusListsRefs.get + statusList = statusLists(statusListId) + credsInStatusList <- statusListToCredInStatusListStorageRefs(statusList.id).flatMap(_.get.map(_.values.toList)) + } yield CredentialStatusListWithCreds( + id = statusList.id, + walletId = statusList.walletId, + issuer = statusList.issuer, + issued = statusList.issued, + purpose = statusList.purpose, + statusListCredential = statusList.statusListCredential, + size = statusList.size, + lastUsedIndex = statusList.lastUsedIndex, + credentials = credsInStatusList.map { cred => + CredInStatusList( + id = cred.id, + issueCredentialRecordId = cred.issueCredentialRecordId, + statusListIndex = cred.statusListIndex, + isCanceled = cred.isCanceled, + isProcessed = cred.isProcessed, + ) + } + ) } def updateStatusListCredential( diff --git a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceSpecHelper.scala b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceSpecHelper.scala index a03eb88803..8ae2fd6602 100644 --- a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceSpecHelper.scala +++ b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceSpecHelper.scala @@ -3,7 +3,6 @@ package org.hyperledger.identus.pollux.core.service import io.circe.Json import org.hyperledger.identus.agent.walletapi.memory.GenericSecretStorageInMemory import org.hyperledger.identus.agent.walletapi.service.ManagedDIDService -import org.hyperledger.identus.agent.walletapi.storage.GenericSecretStorage import org.hyperledger.identus.castor.core.model.did.PrismDID import org.hyperledger.identus.castor.core.service.DIDService import org.hyperledger.identus.mercury.model.{AttachmentDescriptor, DidId} @@ -18,6 +17,7 @@ import org.hyperledger.identus.pollux.core.repository.{ import org.hyperledger.identus.pollux.prex.{ClaimFormat, Ldp, PresentationDefinition} import org.hyperledger.identus.pollux.vc.jwt.* import org.hyperledger.identus.shared.http.UriResolver +import org.hyperledger.identus.shared.messaging.{MessagingService, MessagingServiceConfig, WalletIdAndRecordId} import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import zio.* @@ -41,6 +41,8 @@ trait CredentialServiceSpecHelper { credentialDefinitionServiceLayer, GenericSecretStorageInMemory.layer, LinkSecretServiceImpl.layer, + (MessagingServiceConfig.inMemoryLayer >>> MessagingService.serviceLayer >>> + MessagingService.producerLayer[UUID, WalletIdAndRecordId]).orDie, CredentialServiceImpl.layer ) diff --git a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/MockPresentationService.scala b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/MockPresentationService.scala index 4f58c2f570..dad75bfcee 100644 --- a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/MockPresentationService.scala +++ b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/MockPresentationService.scala @@ -16,7 +16,7 @@ import org.hyperledger.identus.pollux.core.service.serdes.{AnoncredCredentialPro import org.hyperledger.identus.pollux.sdjwt.{HolderPrivateKey, PresentationCompact} import org.hyperledger.identus.pollux.vc.jwt.{Issuer, PresentationPayload, W3cCredentialPayload} import org.hyperledger.identus.shared.models.* -import zio.{mock, Duration, IO, UIO, URLayer, ZIO, ZLayer} +import zio.{mock, Duration, IO, UIO, URIO, URLayer, ZIO, ZLayer} import zio.json.* import zio.mock.{Mock, Proxy} @@ -329,7 +329,8 @@ object MockPresentationService extends Mock[PresentationService] { state: PresentationRecord.ProtocolState* ): IO[PresentationError, Seq[PresentationRecord]] = ??? - override def findPresentationRecord(recordId: DidCommID): IO[PresentationError, Option[PresentationRecord]] = ??? + override def findPresentationRecord(recordId: DidCommID): URIO[WalletAccessContext, Option[PresentationRecord]] = + ??? override def findPresentationRecordByThreadId( thid: DidCommID diff --git a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceSpecHelper.scala b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceSpecHelper.scala index e438d4687b..1e59c4704a 100644 --- a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceSpecHelper.scala +++ b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceSpecHelper.scala @@ -1,6 +1,5 @@ package org.hyperledger.identus.pollux.core.service -import com.nimbusds.jose.jwk.* import org.hyperledger.identus.agent.walletapi.memory.GenericSecretStorageInMemory import org.hyperledger.identus.castor.core.model.did.DID import org.hyperledger.identus.mercury.{AgentPeerService, PeerDID} @@ -14,6 +13,7 @@ import org.hyperledger.identus.pollux.core.service.uriResolvers.ResourceUrlResol import org.hyperledger.identus.pollux.vc.jwt.* import org.hyperledger.identus.shared.crypto.KmpSecp256k1KeyOps import org.hyperledger.identus.shared.http.UriResolver +import org.hyperledger.identus.shared.messaging.{MessagingService, MessagingServiceConfig, WalletIdAndRecordId} import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import zio.* @@ -42,7 +42,9 @@ trait PresentationServiceSpecHelper { uriResolverLayer, linkSecretLayer, PresentationRepositoryInMemory.layer, - CredentialRepositoryInMemory.layer + CredentialRepositoryInMemory.layer, + (MessagingServiceConfig.inMemoryLayer >>> MessagingService.serviceLayer >>> + MessagingService.producerLayer[UUID, WalletIdAndRecordId]).orDie, ) ++ defaultWalletLayer def createIssuer(did: String): Issuer = { diff --git a/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcCredentialStatusListRepository.scala b/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcCredentialStatusListRepository.scala index f0a9dc2dbe..f4b27410cf 100644 --- a/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcCredentialStatusListRepository.scala +++ b/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcCredentialStatusListRepository.scala @@ -1,6 +1,8 @@ package org.hyperledger.identus.pollux.sql.repository +import cats.implicits.toFunctorOps import doobie.* +import doobie.free.connection.ConnectionOp import doobie.implicits.* import doobie.postgres.* import doobie.postgres.implicits.* @@ -8,8 +10,7 @@ import org.hyperledger.identus.castor.core.model.did.* import org.hyperledger.identus.pollux.core.model.* import org.hyperledger.identus.pollux.core.repository.CredentialStatusListRepository import org.hyperledger.identus.pollux.vc.jwt.{Issuer, StatusPurpose} -import org.hyperledger.identus.pollux.vc.jwt.revocation.{BitString, BitStringError, VCStatusList2021} -import org.hyperledger.identus.pollux.vc.jwt.revocation.BitStringError.* +import org.hyperledger.identus.pollux.vc.jwt.revocation.BitString import org.hyperledger.identus.shared.db.ContextAwareTask import org.hyperledger.identus.shared.db.Implicits.* import org.hyperledger.identus.shared.db.Implicits.given @@ -18,7 +19,7 @@ import zio.* import zio.interop.catz.* import java.time.Instant -import java.util.UUID +import java.util.{Objects, UUID} class JdbcCredentialStatusListRepository(xa: Transactor[ContextAwareTask], xb: Transactor[Task]) extends CredentialStatusListRepository { @@ -47,9 +48,19 @@ class JdbcCredentialStatusListRepository(xa: Transactor[ContextAwareTask], xb: T .orDie } - def getLatestOfTheWallet: URIO[WalletAccessContext, Option[CredentialStatusList]] = { + override def incrementAndGetStatusListIndex( + jwtIssuer: Issuer, + statusListRegistryUrl: String + ): URIO[WalletAccessContext, (UUID, Int)] = { - val cxnIO = + def acquireAdvisoryLock(walletId: WalletId): ConnectionIO[Unit] = { + // Should be specific to this process + val PROCESS_UNIQUE_ID = 235457 + val hashCode = Objects.hash(walletId.hashCode(), PROCESS_UNIQUE_ID) + sql"SELECT pg_advisory_xact_lock($hashCode)".query[Unit].unique.void + } + + def getLatestOfTheWallet: ConnectionIO[Option[CredentialStatusList]] = sql""" | SELECT | id, @@ -62,74 +73,70 @@ class JdbcCredentialStatusListRepository(xa: Transactor[ContextAwareTask], xb: T | last_used_index, | created_at, | updated_at - | FROM public.credential_status_lists order by created_at DESC limit 1 + | FROM public.credential_status_lists + | ORDER BY created_at DESC limit 1 |""".stripMargin .query[CredentialStatusList] .option - cxnIO - .transactWallet(xa) - .orDie - - } + def createNewForTheWallet( + id: UUID, + issuerDid: String, + issued: Instant, + credentialStr: String + ): ConnectionIO[CredentialStatusList] = + sql""" + |INSERT INTO public.credential_status_lists ( + | id, + | issuer, + | issued, + | purpose, + | status_list_credential, + | size, + | last_used_index, + | wallet_id + | ) + |VALUES ( + | $id, + | $issuerDid, + | $issued, + | ${StatusPurpose.Revocation}::public.enum_credential_status_list_purpose, + | $credentialStr::JSON, + | ${BitString.MIN_SL2021_SIZE}, + | 0, + | current_setting('app.current_wallet_id')::UUID + | ) + |RETURNING id, wallet_id, issuer, issued, purpose, status_list_credential, size, last_used_index, created_at, updated_at + """.stripMargin + .query[CredentialStatusList] + .unique - def createNewForTheWallet( - jwtIssuer: Issuer, - statusListRegistryUrl: String - ): URIO[WalletAccessContext, CredentialStatusList] = { - - val id = UUID.randomUUID() - val issued = Instant.now() - val issuerDid = jwtIssuer.did.toString - - val credentialWithEmbeddedProof = for { - bitString <- BitString.getInstance().mapError { - case InvalidSize(message) => new Throwable(message) - case EncodingError(message) => new Throwable(message) - case DecodingError(message) => new Throwable(message) - case IndexOutOfBounds(message) => new Throwable(message) - } - resourcePath = - s"credential-status/$id" - emptyStatusListCredential <- VCStatusList2021 - .build( - vcId = s"$statusListRegistryUrl/credential-status/$id", - revocationData = bitString, - jwtIssuer = jwtIssuer - ) - .mapError(x => new Throwable(x.msg)) - - credentialWithEmbeddedProof <- emptyStatusListCredential.toJsonWithEmbeddedProof - } yield credentialWithEmbeddedProof.spaces2 + def updateLastUsedIndex(statusListId: UUID, lastUsedIndex: Int): ConnectionIO[Int] = + sql""" + | UPDATE public.credential_status_lists + | SET + | last_used_index = $lastUsedIndex, + | updated_at = ${Instant.now()} + | WHERE + | id = $statusListId + |""".stripMargin.update.run (for { - credentialStr <- credentialWithEmbeddedProof - query = sql""" - |INSERT INTO public.credential_status_lists ( - | id, - | issuer, - | issued, - | purpose, - | status_list_credential, - | size, - | last_used_index, - | wallet_id - | ) - |VALUES ( - | $id, - | $issuerDid, - | $issued, - | ${StatusPurpose.Revocation}::public.enum_credential_status_list_purpose, - | $credentialStr::JSON, - | ${BitString.MIN_SL2021_SIZE}, - | 0, - | current_setting('app.current_wallet_id')::UUID - | ) - |RETURNING id, wallet_id, issuer, issued, purpose, status_list_credential, size, last_used_index, created_at, updated_at - """.stripMargin.query[CredentialStatusList].unique - newStatusList <- query.transactWallet(xa) - } yield newStatusList).orDie - + id <- ZIO.succeed(UUID.randomUUID()) + newStatusListVC <- createStatusListVC(jwtIssuer, statusListRegistryUrl, id) + walletCtx <- ZIO.service[WalletAccessContext] + walletId = walletCtx.walletId + cnxIO = for { + _ <- acquireAdvisoryLock(walletId) + maybeStatusList <- getLatestOfTheWallet + statusList <- maybeStatusList match + case Some(csl) if csl.lastUsedIndex < csl.size => cats.free.Free.pure[ConnectionOp, CredentialStatusList](csl) + case _ => createNewForTheWallet(id, jwtIssuer.did.toString, Instant.now(), newStatusListVC) + newIndex = statusList.lastUsedIndex + 1 + _ <- updateLastUsedIndex(statusList.id, newIndex) + } yield (statusList.id, newIndex) + result <- cnxIO.transactWallet(xa) + } yield result).orDie } def allocateSpaceForCredential( @@ -214,9 +221,24 @@ class JdbcCredentialStatusListRepository(xa: Transactor[ContextAwareTask], xb: T } yield () } - def getCredentialStatusListsWithCreds: UIO[List[CredentialStatusListWithCreds]] = { + def getCredentialStatusListIds: UIO[Seq[(WalletId, UUID)]] = { + val cxnIO = + sql""" + | SELECT + | wallet_id, + | id + | FROM public.credential_status_lists + |""".stripMargin + .query[(WalletId, UUID)] + .to[Seq] + cxnIO + .transact(xb) + .orDie + } - // Might need to add wallet Id in the select query, because I'm selecting all of them + def getCredentialStatusListsWithCreds( + statusListId: UUID + ): URIO[WalletAccessContext, CredentialStatusListWithCreds] = { val cxnIO = sql""" | SELECT @@ -235,42 +257,35 @@ class JdbcCredentialStatusListRepository(xa: Transactor[ContextAwareTask], xb: T | cisl.is_processed | FROM public.credential_status_lists csl | LEFT JOIN public.credentials_in_status_list cisl ON csl.id = cisl.credential_status_list_id + | WHERE + | csl.id = $statusListId |""".stripMargin .query[CredentialStatusListWithCred] .to[List] - - val credentialStatusListsWithCredZio = cxnIO - .transact(xb) - .orDie - - for { - credentialStatusListsWithCred <- credentialStatusListsWithCredZio - } yield { - credentialStatusListsWithCred - .groupBy(_.credentialStatusListId) - .map { case (id, items) => - CredentialStatusListWithCreds( - id, - items.head.walletId, - items.head.issuer, - items.head.issued, - items.head.purpose, - items.head.statusListCredential, - items.head.size, - items.head.lastUsedIndex, - items.map { item => - CredInStatusList( - item.credentialInStatusListId, - item.issueCredentialRecordId, - item.statusListIndex, - item.isCanceled, - item.isProcessed, - ) - } + .transactWallet(xa) + .orDie + + cxnIO.map(items => + CredentialStatusListWithCreds( + statusListId, + items.head.walletId, + items.head.issuer, + items.head.issued, + items.head.purpose, + items.head.statusListCredential, + items.head.size, + items.head.lastUsedIndex, + items.map { item => + CredInStatusList( + item.credentialInStatusListId, + item.issueCredentialRecordId, + item.statusListIndex, + item.isCanceled, + item.isProcessed, ) } - .toList - } + ) + ) } def updateStatusListCredential( diff --git a/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcPresentationRepository.scala b/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcPresentationRepository.scala index 1e477640c1..71b4cad3dc 100644 --- a/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcPresentationRepository.scala +++ b/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcPresentationRepository.scala @@ -459,7 +459,6 @@ class JdbcPresentationRepository( | id = $recordId | AND protocol_state = $from """.stripMargin.update - cxnIO.run .transactWallet(xa) .orDie diff --git a/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/MessagingService.scala b/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/MessagingService.scala new file mode 100644 index 0000000000..8c3a60e56e --- /dev/null +++ b/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/MessagingService.scala @@ -0,0 +1,106 @@ +package org.hyperledger.identus.shared.messaging + +import org.hyperledger.identus.shared.messaging.kafka.{InMemoryMessagingService, ZKafkaMessagingServiceImpl} +import zio.{durationInt, Cause, Duration, EnvironmentTag, RIO, RLayer, Task, URIO, URLayer, ZIO, ZLayer} + +import java.time.Instant +trait MessagingService { + def makeConsumer[K, V](groupId: String)(implicit kSerde: Serde[K], vSerde: Serde[V]): Task[Consumer[K, V]] + def makeProducer[K, V]()(implicit kSerde: Serde[K], vSerde: Serde[V]): Task[Producer[K, V]] +} + +object MessagingService { + + case class RetryStep(topicName: String, consumerCount: Int, consumerBackoff: Duration, nextTopicName: Option[String]) + + object RetryStep { + def apply(topicName: String, consumerCount: Int, consumerBackoff: Duration, nextTopicName: String): RetryStep = + RetryStep(topicName, consumerCount, consumerBackoff, Some(nextTopicName)) + } + + def consumeWithRetryStrategy[K: EnvironmentTag, V: EnvironmentTag, HR]( + groupId: String, + handler: Message[K, V] => RIO[HR, Unit], + steps: Seq[RetryStep] + )(implicit kSerde: Serde[K], vSerde: Serde[V]): RIO[HR & Producer[K, V] & MessagingService, Unit] = { + for { + messagingService <- ZIO.service[MessagingService] + messageProducer <- ZIO.service[Producer[K, V]] + _ <- ZIO.foreachPar(steps) { step => + ZIO.foreachPar(1 to step.consumerCount)(_ => + for { + consumer <- messagingService.makeConsumer[K, V](groupId) + _ <- consumer + .consume[HR](step.topicName) { m => + for { + // Wait configured backoff before processing message + millisSpentInQueue <- ZIO.succeed(Instant.now().toEpochMilli - m.timestamp) + sleepDelay = step.consumerBackoff.toMillis - millisSpentInQueue + _ <- ZIO.when(sleepDelay > 0)(ZIO.sleep(Duration.fromMillis(sleepDelay))) + _ <- handler(m) + .catchAll { t => + for { + _ <- ZIO.logErrorCause(s"Error processing message: ${m.key} ", Cause.fail(t)) + _ <- step.nextTopicName match + case Some(name) => + messageProducer + .produce(name, m.key, m.value) + .catchAll(t => + ZIO.logErrorCause("Unable to send message to the next topic", Cause.fail(t)) + ) + case None => ZIO.unit + } yield () + } + .catchAllDefect(t => ZIO.logErrorCause(s"Defect processing message: ${m.key} ", Cause.fail(t))) + } yield () + } + .debug + .fork + } yield () + ) + } + } yield () + } + + def consume[K: EnvironmentTag, V: EnvironmentTag, HR]( + groupId: String, + topicName: String, + consumerCount: Int, + handler: Message[K, V] => RIO[HR, Unit] + )(implicit kSerde: Serde[K], vSerde: Serde[V]): RIO[HR & Producer[K, V] & MessagingService, Unit] = + consumeWithRetryStrategy(groupId, handler, Seq(RetryStep(topicName, consumerCount, 0.seconds, None))) + + val serviceLayer: URLayer[MessagingServiceConfig, MessagingService] = + ZLayer + .service[MessagingServiceConfig] + .flatMap(config => + if (config.get.kafkaEnabled) ZKafkaMessagingServiceImpl.layer + else InMemoryMessagingService.layer + ) + + def producerLayer[K: EnvironmentTag, V: EnvironmentTag](implicit + kSerde: Serde[K], + vSerde: Serde[V] + ): RLayer[MessagingService, Producer[K, V]] = ZLayer.fromZIO(for { + messagingService <- ZIO.service[MessagingService] + producer <- messagingService.makeProducer[K, V]() + } yield producer) + + def consumerLayer[K: EnvironmentTag, V: EnvironmentTag](groupId: String)(implicit + kSerde: Serde[K], + vSerde: Serde[V] + ): RLayer[MessagingService, Consumer[K, V]] = ZLayer.fromZIO(for { + messagingService <- ZIO.service[MessagingService] + consumer <- messagingService.makeConsumer[K, V](groupId) + } yield consumer) + +} + +case class Message[K, V](key: K, value: V, offset: Long, timestamp: Long) + +trait Consumer[K, V] { + def consume[HR](topic: String, topics: String*)(handler: Message[K, V] => URIO[HR, Unit]): RIO[HR, Unit] +} +trait Producer[K, V] { + def produce(topic: String, key: K, value: V): Task[Unit] +} diff --git a/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/MessagingServiceConfig.scala b/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/MessagingServiceConfig.scala new file mode 100644 index 0000000000..dd63c1a424 --- /dev/null +++ b/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/MessagingServiceConfig.scala @@ -0,0 +1,58 @@ +package org.hyperledger.identus.shared.messaging + +import zio.{ULayer, ZLayer} + +import java.time.Duration + +case class MessagingServiceConfig( + connectFlow: ConsumerJobConfig, + issueFlow: ConsumerJobConfig, + presentFlow: ConsumerJobConfig, + didStateSync: ConsumerJobConfig, + statusListSync: ConsumerJobConfig, + inMemoryQueueCapacity: Int, + kafkaEnabled: Boolean, + kafka: Option[KafkaConfig] +) + +final case class ConsumerJobConfig( + consumerCount: Int, + retryStrategy: Option[ConsumerRetryStrategy] +) + +final case class ConsumerRetryStrategy( + maxRetries: Int, + initialDelay: Duration, + maxDelay: Duration +) + +final case class KafkaConfig( + bootstrapServers: String, + consumers: KafkaConsumersConfig +) + +final case class KafkaConsumersConfig( + autoCreateTopics: Boolean, + maxPollRecords: Int, + maxPollInterval: Duration, + pollTimeout: Duration, + rebalanceSafeCommits: Boolean +) + +object MessagingServiceConfig { + + val inMemoryLayer: ULayer[MessagingServiceConfig] = + ZLayer.succeed( + MessagingServiceConfig( + ConsumerJobConfig(1, None), + ConsumerJobConfig(1, None), + ConsumerJobConfig(1, None), + ConsumerJobConfig(1, None), + ConsumerJobConfig(1, None), + 100, + false, + None + ) + ) + +} diff --git a/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/Serde.scala b/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/Serde.scala new file mode 100644 index 0000000000..94eadf3849 --- /dev/null +++ b/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/Serde.scala @@ -0,0 +1,55 @@ +package org.hyperledger.identus.shared.messaging + +import org.hyperledger.identus.shared.models.WalletId + +import java.nio.charset.StandardCharsets +import java.nio.ByteBuffer +import java.util.UUID + +case class ByteArrayWrapper(ba: Array[Byte]) + +trait Serde[T] { + def serialize(t: T): Array[Byte] + def deserialize(ba: Array[Byte]): T +} + +object Serde { + given byteArraySerde: Serde[ByteArrayWrapper] = new Serde[ByteArrayWrapper] { + override def serialize(t: ByteArrayWrapper): Array[Byte] = t.ba + override def deserialize(ba: Array[Byte]): ByteArrayWrapper = ByteArrayWrapper(ba) + } + + given stringSerde: Serde[String] = new Serde[String] { + override def serialize(t: String): Array[Byte] = t.getBytes() + override def deserialize(ba: Array[Byte]): String = new String(ba, StandardCharsets.UTF_8) + } + + given intSerde: Serde[Int] = new Serde[Int] { + override def serialize(t: Int): Array[Byte] = { + val buffer = java.nio.ByteBuffer.allocate(4) + buffer.putInt(t) + buffer.array() + } + override def deserialize(ba: Array[Byte]): Int = ByteBuffer.wrap(ba).getInt() + } + + given uuidSerde: Serde[UUID] = new Serde[UUID] { + override def serialize(t: UUID): Array[Byte] = { + val buffer = java.nio.ByteBuffer.allocate(16) + buffer.putLong(t.getMostSignificantBits) + buffer.putLong(t.getLeastSignificantBits) + buffer.array() + } + override def deserialize(ba: Array[Byte]): UUID = { + val byteBuffer = ByteBuffer.wrap(ba) + val high = byteBuffer.getLong + val low = byteBuffer.getLong + new UUID(high, low) + } + } + + given walletIdSerde(using uuidSerde: Serde[UUID]): Serde[WalletId] = new Serde[WalletId] { + override def serialize(w: WalletId): Array[Byte] = uuidSerde.serialize(w.toUUID) + override def deserialize(ba: Array[Byte]): WalletId = WalletId.fromUUID(uuidSerde.deserialize(ba)) + } +} diff --git a/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/WalletIdAndRecordId.scala b/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/WalletIdAndRecordId.scala new file mode 100644 index 0000000000..ff1c9e8d76 --- /dev/null +++ b/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/WalletIdAndRecordId.scala @@ -0,0 +1,20 @@ +package org.hyperledger.identus.shared.messaging + +import zio.json.{DecoderOps, DeriveJsonDecoder, DeriveJsonEncoder, EncoderOps, JsonDecoder, JsonEncoder} + +import java.nio.charset.StandardCharsets +import java.util.UUID + +case class WalletIdAndRecordId(walletId: UUID, recordId: UUID) + +object WalletIdAndRecordId { + given encoder: JsonEncoder[WalletIdAndRecordId] = DeriveJsonEncoder.gen[WalletIdAndRecordId] + given decoder: JsonDecoder[WalletIdAndRecordId] = DeriveJsonDecoder.gen[WalletIdAndRecordId] + given ser: Serde[WalletIdAndRecordId] = new Serde[WalletIdAndRecordId] { + override def serialize(t: WalletIdAndRecordId): Array[Byte] = t.toJson.getBytes(StandardCharsets.UTF_8) + override def deserialize(ba: Array[Byte]): WalletIdAndRecordId = + new String(ba, StandardCharsets.UTF_8) + .fromJson[WalletIdAndRecordId] + .getOrElse(throw RuntimeException("Deserialization Error WalletIdAndRecordId")) + } +} diff --git a/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/kafka/InMemoryMessagingService.scala b/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/kafka/InMemoryMessagingService.scala new file mode 100644 index 0000000000..54d8c935c2 --- /dev/null +++ b/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/kafka/InMemoryMessagingService.scala @@ -0,0 +1,146 @@ +package org.hyperledger.identus.shared.messaging.kafka + +import org.hyperledger.identus.shared.messaging.* +import org.hyperledger.identus.shared.messaging.kafka.InMemoryMessagingService.* +import zio.* +import zio.concurrent.ConcurrentMap +import zio.stream.* + +import java.util.concurrent.TimeUnit + +case class ConsumerGroupKey(groupId: GroupId, topic: Topic) + +class InMemoryMessagingService( + topicQueues: ConcurrentMap[Topic, (Queue[Message[_, _]], Ref[Offset])], + queueCapacity: Int, + processedMessagesMap: ConcurrentMap[ + ConsumerGroupKey, + ConcurrentMap[Offset, TimeStamp] + ] +) extends MessagingService { + + override def makeConsumer[K, V](groupId: String)(using kSerde: Serde[K], vSerde: Serde[V]): Task[Consumer[K, V]] = { + ZIO.succeed(new InMemoryConsumer[K, V](groupId, topicQueues, processedMessagesMap)) + } + + override def makeProducer[K, V]()(using kSerde: Serde[K], vSerde: Serde[V]): Task[Producer[K, V]] = + ZIO.succeed(new InMemoryProducer[K, V](topicQueues, queueCapacity)) +} + +class InMemoryConsumer[K, V]( + groupId: GroupId, + topicQueues: ConcurrentMap[Topic, (Queue[Message[_, _]], Ref[Offset])], + processedMessagesMap: ConcurrentMap[ConsumerGroupKey, ConcurrentMap[Offset, TimeStamp]] +) extends Consumer[K, V] { + override def consume[HR](topic: String, topics: String*)(handler: Message[K, V] => URIO[HR, Unit]): RIO[HR, Unit] = { + val allTopics = topic +: topics + def getQueueStream(topic: String): ZStream[Any, Nothing, (String, Message[K, V])] = + ZStream.repeatZIO { + topicQueues.get(topic).flatMap { + case Some((queue, _)) => + ZIO.debug(s"Connected to queue for topic $topic in group $groupId") *> + ZIO.succeed(ZStream.fromQueue(queue).collect { case msg: Message[K, V] @unchecked => (topic, msg) }) + case None => + ZIO.sleep(1.second) *> ZIO.succeed(ZStream.empty) + } + }.flatten + + val streams = allTopics.map(getQueueStream) + ZStream + .mergeAllUnbounded()(streams: _*) + .tap { case (topic, msg) => ZIO.log(s"Processing message in group $groupId, topic:$topic : $msg") } + .filterZIO { case (topic, msg) => + for { + currentTime <- Clock.currentTime(TimeUnit.MILLISECONDS) + key = ConsumerGroupKey(groupId, topic) + topicProcessedMessages <- processedMessagesMap.get(key).flatMap { + case Some(map) => ZIO.succeed(map) + case None => + for { + newMap <- ConcurrentMap.empty[Offset, TimeStamp] + _ <- processedMessagesMap.put(key, newMap) + } yield newMap + } + isNew <- topicProcessedMessages + .putIfAbsent(Offset(msg.offset), TimeStamp(currentTime)) + .map(_.isEmpty) + } yield isNew + } + .mapZIO { case (_, msg) => handler(msg) } + .tap(_ => ZIO.log(s"Message processed in group $groupId, topic:$topic")) + .runDrain + } +} + +class InMemoryProducer[K, V]( + topicQueues: ConcurrentMap[Topic, (Queue[Message[_, _]], Ref[Offset])], + queueCapacity: Int +) extends Producer[K, V] { + override def produce(topic: String, key: K, value: V): Task[Unit] = for { + queueAndOffsetRef <- topicQueues.get(topic).flatMap { + case Some(qAndOffSetRef) => ZIO.succeed(qAndOffSetRef) + case None => + for { + newQueue <- Queue.sliding[Message[_, _]](queueCapacity) + newOffSetRef <- Ref.make(Offset(0L)) + _ <- topicQueues.put(topic, (newQueue, newOffSetRef)) + } yield (newQueue, newOffSetRef) + } + (queue, offsetRef) = queueAndOffsetRef + currentTime <- Clock.currentTime(TimeUnit.MILLISECONDS) + messageId <- offsetRef.updateAndGet(x => Offset(x.value + 1)) // unique atomic id incremented per topic + _ <- queue.offer(Message(key, value, messageId.value, currentTime)) + } yield () +} + +object InMemoryMessagingService { + type Topic = String + type GroupId = String + + opaque type Offset = Long + object Offset: + def apply(value: Long): Offset = value + extension (id: Offset) def value: Long = id + + opaque type TimeStamp = Long + object TimeStamp: + def apply(value: Long): TimeStamp = value + extension (ts: TimeStamp) def value: Long = ts + + val layer: URLayer[MessagingServiceConfig, MessagingService] = + ZLayer.fromZIO { + for { + config <- ZIO.service[MessagingServiceConfig] + queueMap <- ConcurrentMap.empty[Topic, (Queue[Message[_, _]], Ref[Offset])] + processedMessagesMap <- ConcurrentMap.empty[ConsumerGroupKey, ConcurrentMap[Offset, TimeStamp]] + _ <- cleanupTaskForProcessedMessages(processedMessagesMap) + } yield new InMemoryMessagingService(queueMap, config.inMemoryQueueCapacity, processedMessagesMap) + } + + private def cleanupTaskForProcessedMessages( + processedMessagesMap: ConcurrentMap[ConsumerGroupKey, ConcurrentMap[Offset, TimeStamp]], + maxAge: Duration = 60.minutes // Maximum age for entries + ): UIO[Unit] = { + def cleanupOldEntries(map: ConcurrentMap[Offset, TimeStamp]): UIO[Unit] = for { + currentTime <- Clock.currentTime(TimeUnit.MILLISECONDS) + entries <- map.toList + _ <- ZIO.foreachDiscard(entries) { case (key, timestamp) => + if (currentTime - timestamp > maxAge.toMillis) + map.remove(key) *> ZIO.log(s"Removed old entry with key: $key and timestamp: $timestamp") + else + ZIO.unit + } + } yield () + + (for { + entries <- processedMessagesMap.toList + _ <- ZIO.foreachDiscard(entries) { case (key, map) => + ZIO.log(s"Cleaning up entries for group: ${key.groupId} and topic: ${key.topic}") *> + cleanupOldEntries(map) + } + } yield ()) + .repeat(Schedule.spaced(10.minutes)) + .fork + .unit + } +} diff --git a/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/kafka/ZKafkaMessagingServiceImpl.scala b/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/kafka/ZKafkaMessagingServiceImpl.scala new file mode 100644 index 0000000000..9180fc4d62 --- /dev/null +++ b/shared/core/src/main/scala/org/hyperledger/identus/shared/messaging/kafka/ZKafkaMessagingServiceImpl.scala @@ -0,0 +1,136 @@ +package org.hyperledger.identus.shared.messaging.kafka + +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.common.header.Headers +import org.hyperledger.identus.shared.messaging.* +import zio.{Duration, RIO, Task, URIO, URLayer, ZIO, ZLayer} +import zio.kafka.consumer.{ + Consumer as ZKConsumer, + ConsumerSettings as ZKConsumerSettings, + Subscription as ZKSubscription +} +import zio.kafka.producer.{Producer as ZKProducer, ProducerSettings as ZKProducerSettings} +import zio.kafka.serde.{Deserializer as ZKDeserializer, Serializer as ZKSerializer} + +class ZKafkaMessagingServiceImpl( + bootstrapServers: List[String], + autoCreateTopics: Boolean, + maxPollRecords: Int, + maxPollInterval: Duration, + pollTimeout: Duration, + rebalanceSafeCommits: Boolean +) extends MessagingService { + override def makeConsumer[K, V](groupId: String)(implicit kSerde: Serde[K], vSerde: Serde[V]): Task[Consumer[K, V]] = + ZIO.succeed( + new ZKafkaConsumerImpl[K, V]( + bootstrapServers, + groupId, + kSerde, + vSerde, + autoCreateTopics, + maxPollRecords, + maxPollInterval, + pollTimeout, + rebalanceSafeCommits + ) + ) + + override def makeProducer[K, V]()(implicit kSerde: Serde[K], vSerde: Serde[V]): Task[Producer[K, V]] = + ZIO.succeed(new ZKafkaProducerImpl[K, V](bootstrapServers, kSerde, vSerde)) +} + +object ZKafkaMessagingServiceImpl { + val layer: URLayer[MessagingServiceConfig, MessagingService] = + ZLayer.fromZIO { + for { + config <- ZIO.service[MessagingServiceConfig] + kafkaConfig <- config.kafka match + case Some(cfg) => ZIO.succeed(cfg) + case None => ZIO.dieMessage("Kafka config is undefined") + } yield new ZKafkaMessagingServiceImpl( + kafkaConfig.bootstrapServers.split(',').toList, + kafkaConfig.consumers.autoCreateTopics, + kafkaConfig.consumers.maxPollRecords, + kafkaConfig.consumers.maxPollInterval, + kafkaConfig.consumers.pollTimeout, + kafkaConfig.consumers.rebalanceSafeCommits + ) + } +} + +class ZKafkaConsumerImpl[K, V]( + bootstrapServers: List[String], + groupId: String, + kSerde: Serde[K], + vSerde: Serde[V], + autoCreateTopics: Boolean, + maxPollRecords: Int, + maxPollInterval: Duration, + pollTimeout: Duration, + rebalanceSafeCommits: Boolean +) extends Consumer[K, V] { + private val zkConsumer = ZLayer.scoped( + ZKConsumer.make( + ZKConsumerSettings(bootstrapServers) + .withProperty(ConsumerConfig.ALLOW_AUTO_CREATE_TOPICS_CONFIG, autoCreateTopics.toString) + .withGroupId(groupId) + // 'max.poll.records' default is 500. This is a Kafka property. + .withMaxPollRecords(maxPollRecords) + // 'max.poll.interval.ms' default is 5 minutes. This is a Kafka property. + .withMaxPollInterval(maxPollInterval) // Should be max.poll.records x 'max processing time per record' + // 'pollTimeout' default is 50 millis. This is a ZIO Kafka property. + .withPollTimeout(pollTimeout) + // .withOffsetRetrieval(OffsetRetrieval.Auto(AutoOffsetStrategy.Earliest)) + .withRebalanceSafeCommits(rebalanceSafeCommits) + // .withMaxRebalanceDuration(30.seconds) + ) + ) + + private val zkKeyDeserializer = new ZKDeserializer[Any, K] { + override def deserialize(topic: String, headers: Headers, data: Array[Byte]): RIO[Any, K] = + ZIO.succeed(kSerde.deserialize(data)) + } + + private val zkValueDeserializer = new ZKDeserializer[Any, V] { + override def deserialize(topic: String, headers: Headers, data: Array[Byte]): RIO[Any, V] = + ZIO.succeed(vSerde.deserialize(data)) + } + + override def consume[HR](topic: String, topics: String*)(handler: Message[K, V] => URIO[HR, Unit]): RIO[HR, Unit] = + ZKConsumer + .plainStream(ZKSubscription.topics(topic, topics*), zkKeyDeserializer, zkValueDeserializer) + .provideSomeLayer(zkConsumer) + .mapZIO(record => + handler(Message(record.key, record.value, record.offset.offset, record.timestamp)).as(record.offset) + ) + .aggregateAsync(ZKConsumer.offsetBatches) + .mapZIO(_.commit) + .runDrain +} + +class ZKafkaProducerImpl[K, V](bootstrapServers: List[String], kSerde: Serde[K], vSerde: Serde[V]) + extends Producer[K, V] { + private val zkProducer = ZLayer.scoped( + ZKProducer.make( + ZKProducerSettings(bootstrapServers) + ) + ) + + private val zkKeySerializer = new ZKSerializer[Any, K] { + override def serialize(topic: String, headers: Headers, value: K): RIO[Any, Array[Byte]] = + ZIO.succeed(kSerde.serialize(value)) + } + + private val zkValueSerializer = new ZKSerializer[Any, V] { + override def serialize(topic: String, headers: Headers, value: V): RIO[Any, Array[Byte]] = + ZIO.succeed(vSerde.serialize(value)) + } + + override def produce(topic: String, key: K, value: V): Task[Unit] = + ZKProducer + .produce(topic, key, value, zkKeySerializer, zkValueSerializer) + .tap(metadata => ZIO.logInfo(s"Message produced: ${metadata.offset()}")) + .map(_ => ()) + .provideSome(zkProducer) + +} diff --git a/tests/integration-tests/src/test/resources/containers/agent.yml b/tests/integration-tests/src/test/resources/containers/agent.yml index 5d3048eea3..74bf287ada 100644 --- a/tests/integration-tests/src/test/resources/containers/agent.yml +++ b/tests/integration-tests/src/test/resources/containers/agent.yml @@ -42,6 +42,8 @@ services: REST_SERVICE_URL: POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: API_KEY_ENABLED: + STATUS_LIST_SYNC_TRIGGER_RECURRENCE_DELAY: 5 seconds + DID_STATE_SYNC_TRIGGER_RECURRENCE_DELAY: 5 seconds # Secret storage configuration SECRET_STORAGE_BACKEND: VAULT_ADDR: "http://host.docker.internal:${VAULT_HTTP_PORT}" @@ -52,9 +54,13 @@ services: KEYCLOAK_CLIENT_ID: KEYCLOAK_CLIENT_SECRET: KEYCLOAK_UMA_AUTO_UPGRADE_RPT: true # no configurable at the moment + # Kafka Messaging Service + DEFAULT_KAFKA_ENABLED: true depends_on: postgres: condition: service_healthy + init-kafka: + condition: service_healthy ports: - "${AGENT_DIDCOMM_PORT}:${AGENT_DIDCOMM_PORT}" - "${AGENT_HTTP_PORT}:${AGENT_HTTP_PORT}" @@ -72,3 +78,91 @@ services: # Extra hosts for Linux networking extra_hosts: - "host.docker.internal:host-gateway" + zookeeper: + image: confluentinc/cp-zookeeper:latest + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + # ports: + # - 22181:2181 + + kafka: + image: confluentinc/cp-kafka:latest + depends_on: + - zookeeper + # ports: + # - 29092:29092 + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: false + healthcheck: + test: + [ + "CMD", + "kafka-topics", + "--list", + "--bootstrap-server", + "localhost:9092", + ] + interval: 5s + timeout: 10s + retries: 5 + + init-kafka: + image: confluentinc/cp-kafka:latest + depends_on: + kafka: + condition: service_healthy + entrypoint: ["/bin/sh", "-c"] + command: | + " + # blocks until kafka is reachable + kafka-topics --bootstrap-server kafka:9092 --list + echo -e 'Creating kafka topics' + + # Connect + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic connect --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic connect-retry-1 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic connect-retry-2 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic connect-retry-3 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic connect-retry-4 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic connect-DLQ --replication-factor 1 --partitions 1 + + # Issue + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic issue --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic issue-retry-1 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic issue-retry-2 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic issue-retry-3 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic issue-retry-4 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic issue-DLQ --replication-factor 1 --partitions 1 + + # Present + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic present --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic present-retry-1 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic present-retry-2 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic present-retry-3 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic present-retry-4 --replication-factor 1 --partitions 5 + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic present-DLQ --replication-factor 1 --partitions 1 + + # DID Publication State Sync + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic sync-did-state --replication-factor 1 --partitions 5 + + # Status List Sync + kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic sync-status-list --replication-factor 1 --partitions 5 + + tail -f /dev/null + " + healthcheck: + test: + [ + "CMD-SHELL", + "kafka-topics --bootstrap-server kafka:9092 --list | grep -q 'sync-status-list'", + ] + interval: 5s + timeout: 10s + retries: 5 From 7317f59687071310b6e2fbc5eaaf766b8cb1092d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Oct 2024 08:43:25 +0000 Subject: [PATCH 05/10] build: sbt and plugins dependency update (#1371) Signed-off-by: Hyperledger Bot Co-authored-by: Hyperledger Bot Co-authored-by: Yurii Shynbuiev --- project/build.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/build.properties b/project/build.properties index ee4c672cd0..0b699c3052 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.1 +sbt.version=1.10.2 From 1c0a105983d0e14350858b58cc3ae895da3d8423 Mon Sep 17 00:00:00 2001 From: Yurii Shynbuiev Date: Thu, 10 Oct 2024 13:35:50 +0700 Subject: [PATCH 06/10] test: update test dependencies for e2e and performance tests (#1389) Signed-off-by: Yurii Shynbuiev --- tests/integration-tests/build.gradle.kts | 2 +- tests/performance-tests/agent-performance-tests-k6/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration-tests/build.gradle.kts b/tests/integration-tests/build.gradle.kts index 7a653b4398..4e2020e689 100644 --- a/tests/integration-tests/build.gradle.kts +++ b/tests/integration-tests/build.gradle.kts @@ -33,7 +33,7 @@ dependencies { testImplementation("io.ktor:ktor-server-netty:2.3.0") testImplementation("io.ktor:ktor-client-apache:2.3.0") // RestAPI client - testImplementation("org.hyperledger.identus:cloud-agent-client-kotlin:1.39.1-bbcedb1") + testImplementation("org.hyperledger.identus:cloud-agent-client-kotlin:1.39.1-19ab426") // Test helpers library testImplementation("io.iohk.atala:atala-automation:0.4.0") // Hoplite for configuration diff --git a/tests/performance-tests/agent-performance-tests-k6/package.json b/tests/performance-tests/agent-performance-tests-k6/package.json index 65d5b5f020..6e41d3a533 100644 --- a/tests/performance-tests/agent-performance-tests-k6/package.json +++ b/tests/performance-tests/agent-performance-tests-k6/package.json @@ -26,7 +26,7 @@ "webpack": "webpack" }, "dependencies": { - "@hyperledger/identus-cloud-agent-client-ts": "^1.39.1-bbcedb1", + "@hyperledger/identus-cloud-agent-client-ts": "^1.39.1-19ab426", "uuid": "^9.0.0" } } From f74cc3e374a7c2222b078363e09475cd3cb971b0 Mon Sep 17 00:00:00 2001 From: Shailesh Patil <53746241+mineme0110@users.noreply.github.com> Date: Thu, 17 Oct 2024 11:20:01 +0100 Subject: [PATCH 07/10] test: Add integration test for conectionless issuance (#1395) Signed-off-by: mineme0110 Signed-off-by: Hyperledger Bot Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Hyperledger Bot --- .../connectionless/ConnectionLessSteps.kt | 81 +++++++++++++++++++ .../features/credential/jwt/issuance.feature | 10 +++ .../credential/sdjwt/issuance.feature | 11 +++ 3 files changed, 102 insertions(+) create mode 100644 tests/integration-tests/src/test/kotlin/steps/connectionless/ConnectionLessSteps.kt diff --git a/tests/integration-tests/src/test/kotlin/steps/connectionless/ConnectionLessSteps.kt b/tests/integration-tests/src/test/kotlin/steps/connectionless/ConnectionLessSteps.kt new file mode 100644 index 0000000000..627281d15a --- /dev/null +++ b/tests/integration-tests/src/test/kotlin/steps/connectionless/ConnectionLessSteps.kt @@ -0,0 +1,81 @@ +package steps.connectionless + +import interactions.Post +import interactions.body +import io.cucumber.java.en.* +import io.iohk.atala.automation.extensions.get +import io.iohk.atala.automation.serenity.ensure.Ensure +import net.serenitybdd.rest.SerenityRest +import net.serenitybdd.screenplay.Actor +import org.apache.http.HttpStatus.SC_CREATED +import org.apache.http.HttpStatus.SC_OK +import org.hyperledger.identus.client.models.* + +class ConnectionLessSteps { + + @When("{actor} creates a {string} credential offer invitation with {string} form DID") + fun inviterGeneratesACredentialOfferInvitation(issuer: Actor, credentialFormat: String, didForm: String) { + val claims = linkedMapOf( + "firstName" to "Automation", + "lastName" to "Execution", + "email" to "email@example.com", + ) + val did: String = if (didForm == "short") { + issuer.recall("shortFormDid") + } else { + issuer.recall("longFormDid") + } + val credentialOfferRequest = CreateIssueCredentialRecordRequest( + claims = claims, + issuingDID = did, + validityPeriod = 3600.0, + credentialFormat = credentialFormat, + automaticIssuance = false, + goalCode = "issue-vc", + goal = "To issue a Faber College Graduate credential", + ) + + issuer.attemptsTo( + Post.to("/issue-credentials/credential-offers/invitation").body(credentialOfferRequest), + ) + + val credentialRecord = SerenityRest.lastResponse().get() + + issuer.attemptsTo( + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_CREATED), + Ensure.that(credentialRecord.goalCode!!).isEqualTo("issue-vc"), + Ensure.that(credentialRecord.protocolState).isEqualTo(IssueCredentialRecord.ProtocolState.INVITATION_GENERATED), + Ensure.that(credentialRecord.role).isEqualTo(IssueCredentialRecord.Role.ISSUER), + ) + + // Acme remembers connection to send it out of band to Bob + issuer.remember("credentialRecord", credentialRecord) + issuer.remember("thid", credentialRecord.thid) + } + + @And("{actor} accepts the credential offer invitation from {actor}") + fun holderAcceptsCredentialOfferInvitation(holder: Actor, issuer: Actor) { + // Bob accepts connection using achieved out-of-band invitation + val credentialOfferInvitationRecord = issuer.recall("credentialRecord") + holder.attemptsTo( + Post.to("/issue-credentials/credential-offers/accept-invitation") + .with { + it.body( + AcceptCredentialOfferInvitation( + credentialOfferInvitationRecord.invitation?.invitationUrl?.split("=")?.getOrNull(1) + ?: throw IllegalStateException("Invalid invitation URL format"), + ), + ) + }, + ) + val holderIssueCredentialRecord = SerenityRest.lastResponse().get() + + holder.attemptsTo( + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), + Ensure.that(holderIssueCredentialRecord.protocolState).isEqualTo(IssueCredentialRecord.ProtocolState.OFFER_RECEIVED), + Ensure.that(holderIssueCredentialRecord.role).isEqualTo(IssueCredentialRecord.Role.HOLDER), + ) + holder.remember("recordId", holderIssueCredentialRecord.recordId) + holder.remember("thid", holderIssueCredentialRecord.thid) + } +} diff --git a/tests/integration-tests/src/test/resources/features/credential/jwt/issuance.feature b/tests/integration-tests/src/test/resources/features/credential/jwt/issuance.feature index 2e9689f772..79b541c1f6 100644 --- a/tests/integration-tests/src/test/resources/features/credential/jwt/issuance.feature +++ b/tests/integration-tests/src/test/resources/features/credential/jwt/issuance.feature @@ -1,6 +1,7 @@ @jwt @issuance Feature: Issue JWT credential + Scenario: Issuing jwt credential with published PRISM DID Given Issuer and Holder have an existing connection And Issuer has a published DID for JWT @@ -39,3 +40,12 @@ Feature: Issue JWT credential And Holder accepts jwt credential offer And Issuer issues the credential Then Holder receives the issued credential + + Scenario: Connectionless issuance of JWT credential using OOB invitation + Given Issuer has a published DID for JWT + And Holder has an unpublished DID for JWT + When Issuer creates a "JWT" credential offer invitation with "short" form DID + And Holder accepts the credential offer invitation from Issuer + And Holder accepts jwt credential offer + And Issuer issues the credential + Then Holder receives the issued credential \ No newline at end of file diff --git a/tests/integration-tests/src/test/resources/features/credential/sdjwt/issuance.feature b/tests/integration-tests/src/test/resources/features/credential/sdjwt/issuance.feature index 29af552150..a96c24f04f 100644 --- a/tests/integration-tests/src/test/resources/features/credential/sdjwt/issuance.feature +++ b/tests/integration-tests/src/test/resources/features/credential/sdjwt/issuance.feature @@ -23,6 +23,17 @@ Feature: Issue SD-JWT credential Then Holder receives the issued credential Then Holder checks the sd-jwt credential contents with holder binding + Scenario: Connectionless issuance of sd-jwt credential with holder binding + And Issuer has a published DID for SD_JWT + And Holder has an unpublished DID for SD_JWT + When Issuer creates a "SDJWT" credential offer invitation with "short" form DID + And Holder accepts the credential offer invitation from Issuer + And Holder accepts credential offer for sd-jwt with 'auth-1' key binding + And Issuer issues the credential + Then Holder receives the issued credential + Then Holder checks the sd-jwt credential contents with holder binding + + # Scenario: Issuing sd-jwt with wrong algorithm # Given Issuer and Holder have an existing connection # When Issuer prepares a custom PRISM DID From 9b64793ee7939860973108a8b30bc0b48a840518 Mon Sep 17 00:00:00 2001 From: bvoiturier Date: Fri, 18 Oct 2024 09:44:50 +0200 Subject: [PATCH 08/10] fix: handle unsupported PIURI found in DIDComm messages accordingly (#1399) Signed-off-by: Benjamin Voiturier --- .../didcomm/controller/DIDCommControllerError.scala | 8 +++++++- .../didcomm/controller/DIDCommControllerImpl.scala | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/didcomm/controller/DIDCommControllerError.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/didcomm/controller/DIDCommControllerError.scala index 5a27032b75..4c7208fda0 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/didcomm/controller/DIDCommControllerError.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/didcomm/controller/DIDCommControllerError.scala @@ -1,6 +1,6 @@ package org.hyperledger.identus.didcomm.controller -import org.hyperledger.identus.mercury.model.DidId +import org.hyperledger.identus.mercury.model.{DidId, PIURI} import org.hyperledger.identus.shared.models.{Failure, KeyId, StatusCode} sealed trait DIDCommControllerError extends Failure { @@ -39,4 +39,10 @@ object DIDCommControllerError { override def userFacingMessage: String = s"The Peer DID does not contain the required key: DID=${did.value}, keyId=${keyId.value}" } + + final case class UnsupportedPIURI(piuri: PIURI) extends DIDCommControllerError { + override def statusCode: StatusCode = StatusCode.UnprocessableContent + override def userFacingMessage: String = + s"The Protocol Identifier URI (URI) found in the DIDComm message is not supported: PIURI=$piuri" + } } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/didcomm/controller/DIDCommControllerImpl.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/didcomm/controller/DIDCommControllerImpl.scala index b910ace0f4..b13d522e03 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/didcomm/controller/DIDCommControllerImpl.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/didcomm/controller/DIDCommControllerImpl.scala @@ -184,8 +184,8 @@ class DIDCommControllerImpl( /* * Unknown Message */ - private val handleUnknownMessage: PartialFunction[Message, UIO[String]] = { case _ => - ZIO.succeed("Unknown Message Type") + private val handleUnknownMessage: PartialFunction[Message, IO[UnsupportedPIURI, String]] = { case msg => + ZIO.fail(UnsupportedPIURI(msg.piuri)) } } From cbd1a03a8aa91c5a5487c54046e4d9305f9d9241 Mon Sep 17 00:00:00 2001 From: Shailesh Patil Date: Tue, 22 Oct 2024 16:04:00 +0100 Subject: [PATCH 09/10] fix: Add key_id missing field (#1403) Signed-off-by: mineme0110 --- .../pollux/sql/repository/JdbcCredentialRepository.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcCredentialRepository.scala b/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcCredentialRepository.scala index 151ebd9e3f..53a3296dc3 100644 --- a/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcCredentialRepository.scala +++ b/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcCredentialRepository.scala @@ -504,7 +504,8 @@ class JdbcCredentialRepository(xa: Transactor[ContextAwareTask], xb: Transactor[ | credential_format, | schema_uris, | credential_definition_uri, - | subject_id + | subject_id, + | key_id | FROM public.issue_credential_records | WHERE 1=1 | AND issue_credential_data IS NOT NULL From b6a895b6e8700f34926589025ac1087ddda5937c Mon Sep 17 00:00:00 2001 From: Shailesh Patil Date: Tue, 22 Oct 2024 16:18:55 +0100 Subject: [PATCH 10/10] chore: I, Shailesh Patil, retroactively sign off on these commits: (#1398) Signed-off-by: mineme0110