diff --git a/storage/sql_migrations/1_first.down.sql b/storage/sql_migrations/1_first.down.sql
index e69de29bb2..8f7c41cf35 100644
--- a/storage/sql_migrations/1_first.down.sql
+++ b/storage/sql_migrations/1_first.down.sql
@@ -0,0 +1,2 @@
+drop table usecaselist_presentations;
+drop table usecaselist_timestamps;
\ No newline at end of file
diff --git a/storage/sql_migrations/1_first.up.sql b/storage/sql_migrations/1_first.up.sql
index e69de29bb2..1b21ea939b 100644
--- a/storage/sql_migrations/1_first.up.sql
+++ b/storage/sql_migrations/1_first.up.sql
@@ -0,0 +1,17 @@
+create table usecaselist_presentations
+(
+ id text not null primary key,
+ list_id text not null,
+ timestamp integer not null,
+ credential_subject_id text not null,
+ presentation_id text not null,
+ presentation_raw text not null,
+ presentation_expiration integer not null,
+ unique (list_id, credential_subject_id)
+);
+
+create table usecaselist_timestamps
+(
+ list_id text not null primary key,
+ timestamp integer not null
+);
\ No newline at end of file
diff --git a/usecase/api/v1/api.go b/usecase/api/v1/api.go
new file mode 100644
index 0000000000..30591b23a3
--- /dev/null
+++ b/usecase/api/v1/api.go
@@ -0,0 +1,11 @@
+package v1
+
+//// lists maps a list name (last path part of use case endpoint) to the list ID
+//lists map[string]string
+//// name is derived from endpoint: it's the last path part of the definition endpoint
+//// It is used to route HTTP GET requests to the correct list.
+//pathParts := strings.Split(definition.Endpoint, "/")
+//name := pathParts[len(pathParts)-1]
+//if name == "" {
+//return nil, fmt.Errorf("can't derive list name from definition endpoint: %s", definition.Endpoint)
+//}
diff --git a/usecase/config.go b/usecase/config.go
new file mode 100644
index 0000000000..083c9b7629
--- /dev/null
+++ b/usecase/config.go
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2023 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package usecase
+
+// Config holds the config for use case listDefinition
+type Config struct {
+ Maintainer MaintainerConfig `koanf:"maintainer"`
+ Definitions DefinitionsConfig `koanf:"definitions"`
+}
+
+type DefinitionsConfig struct {
+ Directory string `koanf:"directory"`
+}
+
+// MaintainerConfig holds the config for the maintainer
+type MaintainerConfig struct {
+ // DefinitionIDs specifies which use case lists the maintainer serves.
+ DefinitionIDs []string `koanf:"definition_ids"`
+ // Directory is the directory where the maintainer stores the lists.
+ Directory string `koanf:"directory"`
+}
+
+// DefaultConfig returns the default configuration.
+func DefaultConfig() Config {
+ return Config{
+ Maintainer: MaintainerConfig{},
+ }
+}
+
+func (c Config) IsMaintainer() bool {
+ return len(c.Maintainer.DefinitionIDs) > 0
+}
diff --git a/usecase/definition.go b/usecase/definition.go
new file mode 100644
index 0000000000..20bf8108ee
--- /dev/null
+++ b/usecase/definition.go
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2023 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package usecase
+
+import (
+ "bytes"
+ "embed"
+ "encoding/json"
+ "github.com/nuts-foundation/nuts-node/vcr/pe"
+ v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2"
+ "github.com/santhosh-tekuri/jsonschema"
+)
+
+//go:embed *.json
+var jsonSchemaFiles embed.FS
+var definitionJsonSchema *jsonschema.Schema
+
+func init() {
+ usecaseDefinitionSchemaData, err := jsonSchemaFiles.ReadFile("usecase-definition-schema.json")
+ if err != nil {
+ panic(err)
+ }
+ const schemaURL = "http://nuts.nl/schemas/usecase-v0.json"
+ if err := v2.Compiler.AddResource(schemaURL, bytes.NewReader(usecaseDefinitionSchemaData)); err != nil {
+ panic(err)
+ }
+ definitionJsonSchema = v2.Compiler.MustCompile(schemaURL)
+}
+
+// Definition holds the definition of a use case list.
+type Definition struct {
+ // ID is the unique identifier of the use case.
+ ID string `json:"id"`
+ // Endpoint is the endpoint where the use case list is served.
+ Endpoint string `json:"endpoint"`
+ // PresentationDefinition specifies the Presentation Definition submissions to the list must conform to,
+ // according to the Presentation Exchange specification.
+ PresentationDefinition pe.PresentationDefinition `json:"presentation_definition"`
+ // PresentationMaxValidity specifies how long submitted presentations are allowed to be valid (in seconds).
+ PresentationMaxValidity int `json:"presentation_max_validity"`
+}
+
+func parseDefinition(data []byte) (*Definition, error) {
+ if err := definitionJsonSchema.Validate(bytes.NewReader(data)); err != nil {
+ return nil, err
+ }
+ var definition Definition
+ if err := json.Unmarshal(data, &definition); err != nil {
+ return nil, err
+ }
+ return &definition, nil
+}
diff --git a/usecase/interface.go b/usecase/interface.go
new file mode 100644
index 0000000000..e3b3d34248
--- /dev/null
+++ b/usecase/interface.go
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2023 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package usecase
+
+// Timestamp is value that references a point in the list.
+// It is used by clients to request new entries since their last query.
+// It's implemented as lamport timestamp (https://en.wikipedia.org/wiki/Lamport_timestamp);
+// it is incremented when a new entry is added to the list.
+// Pass 0 to start at the beginning of the list.
+type Timestamp uint64
diff --git a/usecase/log/logger.go b/usecase/log/logger.go
new file mode 100644
index 0000000000..2c236bcbcf
--- /dev/null
+++ b/usecase/log/logger.go
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2023 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package log
+
+import (
+ "github.com/nuts-foundation/nuts-node/core"
+ "github.com/sirupsen/logrus"
+)
+
+var _logger = logrus.StandardLogger().WithField(core.LogFieldModule, "UseCase")
+
+// Logger returns a logger with the module field set
+func Logger() *logrus.Entry {
+ return _logger
+}
diff --git a/usecase/maintainer.go b/usecase/maintainer.go
new file mode 100644
index 0000000000..f3656ab9e2
--- /dev/null
+++ b/usecase/maintainer.go
@@ -0,0 +1,196 @@
+/*
+ * Copyright (C) 2023 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package usecase
+
+import (
+ "errors"
+ "fmt"
+ "github.com/google/uuid"
+ "github.com/nuts-foundation/go-did/vc"
+ "github.com/nuts-foundation/nuts-node/usecase/log"
+ "github.com/nuts-foundation/nuts-node/vcr/credential"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+ "time"
+)
+
+var ErrListNotFound = errors.New("list not found")
+var ErrPresentationAlreadyExists = errors.New("presentation already exists")
+
+type usecaselistPresentation struct {
+ ID string `gorm:"primaryKey"`
+ ListID string
+ Timestamp uint64
+ CredentialSubjectID string
+ PresentationID string
+ PresentationRaw string
+ PresentationExpiration int
+}
+
+type usecaselistTimestamp struct {
+ ListID string `gorm:"primaryKey"`
+ Timestamp uint64
+}
+
+type maintainer struct {
+ db *gorm.DB
+ lists map[string]Definition
+}
+
+func newMaintainer(db *gorm.DB, lists map[string]Definition) (*maintainer, error) {
+ result := &maintainer{
+ db: db,
+ lists: lists,
+ }
+ // Initialize timestamp table
+ err := result.db.Transaction(func(tx *gorm.DB) error {
+ for listID, _ := range lists {
+ err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&usecaselistTimestamp{ListID: listID, Timestamp: 0}).Error
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("initialize timestamp table: %w", err)
+ }
+ return result, nil
+}
+
+func (m *maintainer) add(useCaseID string, presentation vc.VerifiablePresentation) error {
+ if presentation.Format() != vc.JWTPresentationProofFormat {
+ return errors.New("only JWT presentations are supported")
+ }
+ definition, exists := m.lists[useCaseID]
+ if !exists {
+ return ErrListNotFound
+ }
+ // TODO: Verify VP
+ if presentation.ID == nil {
+ return errors.New("presentation does not have an ID")
+ }
+ expiration := presentation.JWT().Expiration()
+ // VPs should not be valid for too short; unnecessary overhead at the maintainer and clients.
+ // Also protects against expiration not being set at all. The factor is somewhat arbitrary.
+ minValidity := float64(definition.PresentationMaxValidity / 4)
+ if expiration.Sub(time.Now()).Seconds() < minValidity {
+ return fmt.Errorf("presentation is not valid for long enough (min %s)", time.Duration(minValidity)*time.Second)
+ }
+ // VPs should not be valid for too long, as that would prevent the maintainer from pruning them.
+ if int(expiration.Sub(time.Now()).Seconds()) > definition.PresentationMaxValidity {
+ return fmt.Errorf("presentation is valid for too long (max %s)", time.Duration(definition.PresentationMaxValidity)*time.Second)
+ }
+ // TODO: Check credentials aren't valid longer than the presentation
+ credentialSubjectID, err := credential.PresentationSigner(presentation)
+ if err != nil {
+ return err
+ }
+ if exists, err := m.exists(useCaseID, credentialSubjectID.String(), presentation.ID.String()); err != nil {
+ return err
+ } else if exists {
+ return ErrPresentationAlreadyExists
+ }
+ if err := m.prune(); err != nil {
+ return err
+ }
+
+ err = m.db.Transaction(func(tx *gorm.DB) error {
+ // Lock (SELECT FOR UPDATE) usecaselist_timestamps row to prevent concurrent updates to the same list, which could mess up the lamport timestamp.
+ var lamportTimestamp usecaselistTimestamp
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where(usecaselistPresentation{ListID: useCaseID}).
+ Find(&lamportTimestamp).
+ Error; err != nil {
+ return err
+ }
+ // Increment timestamp
+ lamportTimestamp.Timestamp++
+ if err := tx.Save(&lamportTimestamp).Error; err != nil {
+ return err
+ }
+ // Now store the presentation itself
+ return tx.Create(&usecaselistPresentation{
+ ID: uuid.NewString(),
+ ListID: useCaseID,
+ Timestamp: lamportTimestamp.Timestamp,
+ CredentialSubjectID: credentialSubjectID.String(),
+ PresentationID: presentation.ID.String(),
+ PresentationRaw: presentation.Raw(),
+ PresentationExpiration: int(expiration.Unix()),
+ }).Error
+ })
+ if err != nil {
+ return fmt.Errorf("add list '%s' presentation '%s': %w", useCaseID, presentation.ID, err)
+ }
+ return nil
+}
+
+func (m *maintainer) get(listID string, startAt Timestamp) ([]vc.VerifiablePresentation, *Timestamp, error) {
+ if _, exists := m.lists[listID]; !exists {
+ return nil, nil, ErrListNotFound
+ }
+ var rows []usecaselistPresentation
+ err := m.db.Order("timestamp ASC").Find(&rows, "list_id = ? AND timestamp > ?", listID, int(startAt)).Error
+ if err != nil {
+ return nil, nil, fmt.Errorf("get list '%s': %w", listID, err)
+ }
+ timestamp := startAt
+ results := make([]vc.VerifiablePresentation, 0, len(rows))
+ for _, row := range rows {
+ presentation, err := vc.ParseVerifiablePresentation(row.PresentationRaw)
+ if err != nil {
+ return nil, nil, fmt.Errorf("get list '%s' invalid presentation '%s': %w", listID, row.PresentationID, err)
+ }
+ results = append(results, *presentation)
+ timestamp = Timestamp(row.Timestamp)
+ }
+ return results, ×tamp, nil
+}
+
+func (m *maintainer) exists(listID string, credentialSubjectID string, presentationID string) (bool, error) {
+ var count int64
+ if err := m.db.Model(usecaselistPresentation{}).Where(usecaselistPresentation{
+ ListID: listID,
+ CredentialSubjectID: credentialSubjectID,
+ PresentationID: presentationID,
+ }).Count(&count).Error; err != nil {
+ return false, fmt.Errorf("check presentation existence: %w", err)
+ }
+ return count > 0, nil
+}
+
+func (m *maintainer) prune() error {
+ num, err := m.removeExpired()
+ if err != nil {
+ return err
+ }
+ if num > 0 {
+ log.Logger().Debugf("Pruned %d expired presentations", num)
+ }
+ return nil
+}
+
+func (m *maintainer) removeExpired() (int, error) {
+ result := m.db.Where("presentation_expiration < ?", time.Now().Unix()).Delete(usecaselistPresentation{})
+ if result.Error != nil {
+ return 0, fmt.Errorf("prune presentations: %w", result.Error)
+ }
+ return int(result.RowsAffected), nil
+}
diff --git a/usecase/maintainer_test.go b/usecase/maintainer_test.go
new file mode 100644
index 0000000000..a4fd4aa58c
--- /dev/null
+++ b/usecase/maintainer_test.go
@@ -0,0 +1,330 @@
+/*
+ * Copyright (C) 2023 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package usecase
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "fmt"
+ "github.com/google/uuid"
+ "github.com/lestrrat-go/jwx/v2/jwa"
+ "github.com/lestrrat-go/jwx/v2/jwk"
+ "github.com/lestrrat-go/jwx/v2/jws"
+ "github.com/lestrrat-go/jwx/v2/jwt"
+ ssi "github.com/nuts-foundation/go-did"
+ "github.com/nuts-foundation/go-did/did"
+ "github.com/nuts-foundation/go-did/vc"
+ "github.com/nuts-foundation/nuts-node/storage"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "testing"
+ "time"
+)
+
+var testDefinition = Definition{
+ Endpoint: "http://example.com/usecase",
+ PresentationMaxValidity: int((24 * time.Hour).Seconds()),
+}
+var testUseCaseID = "usecase_v1"
+var testDefinitions = map[string]Definition{
+ testUseCaseID: testDefinition,
+ "other": testDefinition,
+}
+var keyPairs map[string]*ecdsa.PrivateKey
+var authorityDID did.DID
+var aliceDID did.DID
+var vcAlice vc.VerifiableCredential
+var vpAlice vc.VerifiablePresentation
+var bobDID did.DID
+var vcBob vc.VerifiableCredential
+var vpBob vc.VerifiablePresentation
+
+func init() {
+ keyPairs = make(map[string]*ecdsa.PrivateKey)
+ authorityDID = did.MustParseDID("did:example:authority")
+ keyPairs[authorityDID.String()], _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ aliceDID = did.MustParseDID("did:example:alice")
+ keyPairs[aliceDID.String()], _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ bobDID = did.MustParseDID("did:example:bob")
+ keyPairs[bobDID.String()], _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
+ vcAlice = createCredential(authorityDID, aliceDID)
+ vpAlice = createPresentation(aliceDID, vcAlice)
+ vcBob = createCredential(authorityDID, bobDID)
+ vpBob = createPresentation(bobDID, vcBob)
+}
+
+func Test_maintainer_exists(t *testing.T) {
+ storageEngine := storage.NewTestStorageEngine(t)
+ require.NoError(t, storageEngine.Start())
+ t.Run("empty list", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ exists, err := m.exists(testUseCaseID, aliceDID.String(), vpAlice.ID.String())
+ assert.NoError(t, err)
+ assert.False(t, exists)
+ })
+ t.Run("non-empty list, no match (other subject and ID)", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ require.NoError(t, m.add(testUseCaseID, vpBob))
+ exists, err := m.exists(testUseCaseID, aliceDID.String(), vpAlice.ID.String())
+ assert.NoError(t, err)
+ assert.False(t, exists)
+ })
+ t.Run("non-empty list, no match (other list)", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ require.NoError(t, m.add(testUseCaseID, vpAlice))
+ exists, err := m.exists("other", aliceDID.String(), vpAlice.ID.String())
+ assert.NoError(t, err)
+ assert.False(t, exists)
+ })
+ t.Run("non-empty list, match", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ require.NoError(t, m.add(testUseCaseID, vpAlice))
+ exists, err := m.exists(testUseCaseID, aliceDID.String(), vpAlice.ID.String())
+ assert.NoError(t, err)
+ assert.True(t, exists)
+ })
+}
+
+func Test_maintainer_add(t *testing.T) {
+ storageEngine := storage.NewTestStorageEngine(t)
+ require.NoError(t, storageEngine.Start())
+ t.Run("ok", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+
+ err := m.add(testUseCaseID, vpAlice)
+ assert.NoError(t, err)
+
+ _, timestamp, err := m.get(testUseCaseID, 0)
+ require.NoError(t, err)
+ assert.Equal(t, Timestamp(1), *timestamp)
+ })
+ t.Run("already exists", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+
+ err := m.add(testUseCaseID, vpAlice)
+ assert.NoError(t, err)
+ err = m.add(testUseCaseID, vpAlice)
+ assert.EqualError(t, err, "presentation already exists")
+ })
+ t.Run("valid for too long", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ def := m.lists[testUseCaseID]
+ def.PresentationMaxValidity = 1
+ m.lists[testUseCaseID] = def
+
+ err := m.add(testUseCaseID, vpAlice)
+ assert.EqualError(t, err, "presentation is valid for too long (max 1s)")
+ })
+ t.Run("not valid long enough", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ def := m.lists[testUseCaseID]
+ def.PresentationMaxValidity = int((24 * time.Hour).Seconds() * 365)
+ m.lists[testUseCaseID] = def
+
+ err := m.add(testUseCaseID, vpAlice)
+ assert.EqualError(t, err, "presentation is not valid for long enough (min 2190h0m0s)")
+ })
+ t.Run("presentation does not contain an ID", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+
+ vpWithoutID := createPresentationWithClaims(aliceDID, func(claims map[string]interface{}) {
+ delete(claims, "jti")
+ }, vcAlice)
+ err := m.add(testUseCaseID, vpWithoutID)
+ assert.EqualError(t, err, "presentation does not have an ID")
+ })
+ t.Run("not a JWT", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ err := m.add(testUseCaseID, vc.VerifiablePresentation{})
+ assert.EqualError(t, err, "only JWT presentations are supported")
+ })
+ t.Run("list unknown", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ err := m.add("unknown", vpAlice)
+ assert.EqualError(t, err, "list not found")
+ })
+}
+
+func Test_maintainer_get(t *testing.T) {
+ storageEngine := storage.NewTestStorageEngine(t)
+ require.NoError(t, storageEngine.Start())
+
+ t.Run("empty list, empty timestamp", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ presentations, timestamp, err := m.get(testUseCaseID, 0)
+ assert.NoError(t, err)
+ assert.Empty(t, presentations)
+ assert.Empty(t, timestamp)
+ })
+ t.Run("1 entry, empty timestamp", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ require.NoError(t, m.add(testUseCaseID, vpAlice))
+ presentations, timestamp, err := m.get(testUseCaseID, 0)
+ assert.NoError(t, err)
+ assert.Equal(t, []vc.VerifiablePresentation{vpAlice}, presentations)
+ assert.Equal(t, Timestamp(1), *timestamp)
+ })
+ t.Run("2 entries, empty timestamp", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ require.NoError(t, m.add(testUseCaseID, vpAlice))
+ require.NoError(t, m.add(testUseCaseID, vpBob))
+ presentations, timestamp, err := m.get(testUseCaseID, 0)
+ assert.NoError(t, err)
+ assert.Equal(t, []vc.VerifiablePresentation{vpAlice, vpBob}, presentations)
+ assert.Equal(t, Timestamp(2), *timestamp)
+ })
+ t.Run("2 entries, start after first", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ require.NoError(t, m.add(testUseCaseID, vpAlice))
+ require.NoError(t, m.add(testUseCaseID, vpBob))
+ presentations, timestamp, err := m.get(testUseCaseID, 1)
+ assert.NoError(t, err)
+ assert.Equal(t, []vc.VerifiablePresentation{vpBob}, presentations)
+ assert.Equal(t, Timestamp(2), *timestamp)
+ })
+ t.Run("2 entries, start after end", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ require.NoError(t, m.add(testUseCaseID, vpAlice))
+ require.NoError(t, m.add(testUseCaseID, vpBob))
+ presentations, timestamp, err := m.get(testUseCaseID, 2)
+ assert.NoError(t, err)
+ assert.Equal(t, []vc.VerifiablePresentation{}, presentations)
+ assert.Equal(t, Timestamp(2), *timestamp)
+ })
+ t.Run("list unknown", func(t *testing.T) {
+ m := setupMaintainer(t, storageEngine)
+ _, _, err := m.get("unknown", 0)
+ assert.EqualError(t, err, "list not found")
+ })
+}
+
+func setupMaintainer(t *testing.T, storageInstance storage.Engine) *maintainer {
+ t.Cleanup(func() {
+ underlyingDB, err := storageInstance.SQLDatabase().DB()
+ require.NoError(t, err)
+ _, err = underlyingDB.Exec("DELETE FROM usecaselist_presentations")
+ require.NoError(t, err)
+ _, err = underlyingDB.Exec("DELETE FROM usecaselist_timestamps")
+ require.NoError(t, err)
+ })
+ // copy testDefinitions to make sure tests don't influence each other
+ testDefinitionsCopy := make(map[string]Definition)
+ for k, v := range testDefinitions {
+ testDefinitionsCopy[k] = v
+ }
+ m, err := newMaintainer(storageInstance.SQLDatabase(), testDefinitionsCopy)
+ require.NoError(t, err)
+ return m
+}
+
+func createCredential(issuerDID did.DID, subjectDID did.DID) vc.VerifiableCredential {
+ vcID := issuerDID
+ vcID.Fragment = uuid.NewString()
+ vcIDURI := vcID.URI()
+ expirationDate := time.Now().Add(time.Hour)
+ result, err := vc.CreateJWTVerifiableCredential(context.Background(), vc.VerifiableCredential{
+ ID: &vcIDURI,
+ Issuer: issuerDID.URI(),
+ IssuanceDate: time.Now(),
+ ExpirationDate: &expirationDate,
+ CredentialSubject: []interface{}{
+ map[string]interface{}{
+ "id": subjectDID.String(),
+ },
+ },
+ }, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) {
+ return signJWT(subjectDID, claims, headers)
+ })
+ if err != nil {
+ panic(err)
+ }
+ return *result
+}
+
+func createPresentation(subjectDID did.DID, credentials ...vc.VerifiableCredential) vc.VerifiablePresentation {
+ return createPresentationWithClaims(subjectDID, func(claims map[string]interface{}) {
+ // do nothing
+ }, credentials...)
+}
+
+func createPresentationWithClaims(subjectDID did.DID, claimVisitor func(map[string]interface{}), credentials ...vc.VerifiableCredential) vc.VerifiablePresentation {
+ headers := map[string]interface{}{
+ jws.TypeKey: "JWT",
+ }
+ claims := map[string]interface{}{
+ jwt.IssuerKey: subjectDID.String(),
+ jwt.SubjectKey: subjectDID.String(),
+ jwt.JwtIDKey: subjectDID.WithoutURL().String() + "#" + uuid.NewString(),
+ "vp": vc.VerifiablePresentation{
+ Type: append([]ssi.URI{ssi.MustParseURI("VerifiablePresentation")}),
+ VerifiableCredential: credentials,
+ },
+ jwt.NotBeforeKey: time.Now().Unix(),
+ jwt.ExpirationKey: time.Now().Add(time.Hour * 8),
+ }
+ claimVisitor(claims)
+ token, err := signJWT(subjectDID, claims, headers)
+ if err != nil {
+ panic(err)
+ }
+ presentation, err := vc.ParseVerifiablePresentation(token)
+ if err != nil {
+ panic(err)
+ }
+ return *presentation
+}
+
+func signJWT(subjectDID did.DID, claims map[string]interface{}, headers map[string]interface{}) (string, error) {
+ // Build JWK
+ signingKey := keyPairs[subjectDID.String()]
+ if signingKey == nil {
+ return "", fmt.Errorf("key not found for DID: %s", subjectDID)
+ }
+ subjectKeyJWK, err := jwk.FromRaw(signingKey)
+ if err != nil {
+ return "", nil
+ }
+ keyID := subjectDID
+ keyID.Fragment = "0"
+ if err := subjectKeyJWK.Set(jwk.AlgorithmKey, jwa.ES256); err != nil {
+ return "", err
+ }
+ if err := subjectKeyJWK.Set(jwk.KeyIDKey, keyID.String()); err != nil {
+ return "", err
+ }
+
+ // Build token
+ token := jwt.New()
+ for k, v := range claims {
+ if err := token.Set(k, v); err != nil {
+ return "", err
+ }
+ }
+ hdr := jws.NewHeaders()
+ for k, v := range headers {
+ if err := hdr.Set(k, v); err != nil {
+ return "", err
+ }
+ }
+ bytes, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, signingKey, jws.WithProtectedHeaders(hdr)))
+ return string(bytes), err
+}
diff --git a/usecase/module.go b/usecase/module.go
new file mode 100644
index 0000000000..a81e7a2272
--- /dev/null
+++ b/usecase/module.go
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2023 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package usecase
+
+import (
+ "errors"
+ "fmt"
+ "github.com/nuts-foundation/go-did/vc"
+ "github.com/nuts-foundation/nuts-node/core"
+ "github.com/nuts-foundation/nuts-node/storage"
+ "os"
+ "path"
+ "strings"
+)
+
+const ModuleName = "Usecase"
+
+var _ core.Injectable = &Module{}
+var _ core.Runnable = &Module{}
+var _ core.Configurable = &Module{}
+var ErrMaintainerModeDisabled = errors.New("server is not a use case list maintainer")
+
+func New(storageInstance storage.Engine) *Module {
+ return &Module{
+ storageInstance: storageInstance,
+ }
+}
+
+type Module struct {
+ config Config
+ storageInstance storage.Engine
+ maintainer *maintainer
+ definitions map[string]Definition
+}
+
+func (m *Module) Configure(_ core.ServerConfig) error {
+ if m.config.Definitions.Directory == "" {
+ return nil
+ }
+ var err error
+ m.definitions, err = loadDefinitions(m.config.Definitions.Directory)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (m *Module) Start() error {
+ if len(m.config.Maintainer.DefinitionIDs) > 0 {
+ // Get the definitions that are enabled for this maintainer
+ maintainedDefinitions := make(map[string]Definition)
+ for _, definitionID := range m.config.Maintainer.DefinitionIDs {
+ if definition, exists := m.definitions[definitionID]; !exists {
+ return fmt.Errorf("definition '%s' not found", definitionID)
+ } else {
+ maintainedDefinitions[definitionID] = definition
+ }
+ }
+ var err error
+ m.maintainer, err = newMaintainer(m.storageInstance.SQLDatabase(), maintainedDefinitions)
+ if err != nil {
+ return fmt.Errorf("unable to start maintainer: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Module) Shutdown() error {
+ return nil
+}
+
+func (m *Module) Name() string {
+ return ModuleName
+}
+
+func (m *Module) Config() interface{} {
+ return &m.config
+}
+
+func (m *Module) Add(listID string, presentation vc.VerifiablePresentation) error {
+ if m.maintainer == nil {
+ return ErrMaintainerModeDisabled
+ }
+ return m.maintainer.add(listID, presentation)
+}
+
+func (m *Module) Get(listID string, startAt Timestamp) ([]vc.VerifiablePresentation, *Timestamp, error) {
+ if m.maintainer == nil {
+ return nil, nil, ErrMaintainerModeDisabled
+ }
+ return m.maintainer.get(listID, startAt)
+}
+
+func loadDefinitions(directory string) (map[string]Definition, error) {
+ entries, err := os.ReadDir(directory)
+ if err != nil {
+ return nil, fmt.Errorf("unable to read definitions directory '%s': %w", directory, err)
+ }
+ result := make(map[string]Definition)
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
+ continue
+ }
+ filePath := path.Join(directory, entry.Name())
+ data, err := os.ReadFile(filePath)
+ if err != nil {
+ return nil, fmt.Errorf("unable to read definition file '%s': %w", filePath, err)
+ }
+ definition, err := parseDefinition(data)
+ if err != nil {
+ return nil, fmt.Errorf("unable to parse definition file '%s': %w", filePath, err)
+ }
+ if _, exists := result[definition.ID]; exists {
+ return nil, fmt.Errorf("duplicate definition ID '%s' in file '%s'", definition.ID, filePath)
+ }
+ result[definition.ID] = *definition
+ }
+ return result, nil
+}
diff --git a/usecase/module_test.go b/usecase/module_test.go
new file mode 100644
index 0000000000..eada85b7a9
--- /dev/null
+++ b/usecase/module_test.go
@@ -0,0 +1,77 @@
+package usecase
+
+import (
+ "github.com/nuts-foundation/go-did/vc"
+ "github.com/nuts-foundation/nuts-node/core"
+ "github.com/nuts-foundation/nuts-node/storage"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "testing"
+)
+
+func TestModule_Maintainer(t *testing.T) {
+ const listID = "urn:nuts.nl:usecase:eOverdrachtDev2023"
+ t.Run("lifecycle", func(t *testing.T) {
+ storageEngine := storage.NewTestStorageEngine(t)
+ require.NoError(t, storageEngine.Start())
+
+ module := New(storageEngine)
+ module.config = DefaultConfig()
+ module.config.Definitions.Directory = "./test/valid"
+ module.config.Maintainer.DefinitionIDs = []string{listID}
+ require.NoError(t, module.Configure(*core.NewServerConfig()))
+ require.NoError(t, module.Start())
+
+ // Empty at first
+ presentations, timestamp, err := module.Get(listID, 0)
+ require.NoError(t, err)
+ require.Equal(t, Timestamp(0), *timestamp)
+ require.Empty(t, presentations)
+
+ // Add a presentation
+ require.NoError(t, module.Add(listID, vpAlice))
+ presentations, timestamp, err = module.Get(listID, 0)
+ require.NoError(t, err)
+ require.Equal(t, Timestamp(1), *timestamp)
+ require.Equal(t, []vc.VerifiablePresentation{vpAlice}, presentations)
+ })
+ t.Run("add - not a maintainer", func(t *testing.T) {
+ err := (&Module{}).Add(listID, vpAlice)
+ require.Equal(t, err, ErrMaintainerModeDisabled)
+ })
+ t.Run("get - not a maintainer", func(t *testing.T) {
+ _, _, err := (&Module{}).Get(listID, 0)
+ require.Equal(t, err, ErrMaintainerModeDisabled)
+ })
+}
+
+func TestModule_Shutdown(t *testing.T) {
+ assert.NoError(t, (&Module{}).Shutdown())
+}
+
+func TestModule_Name(t *testing.T) {
+ assert.Equal(t, "Usecase", (&Module{}).Name())
+}
+
+func Test_loadDefinitions(t *testing.T) {
+ t.Run("duplicate ID", func(t *testing.T) {
+ definitions, err := loadDefinitions("test/duplicate_id")
+ assert.EqualError(t, err, "duplicate definition ID 'urn:nuts.nl:usecase:eOverdrachtDev2023' in file 'test/duplicate_id/2.json'")
+ assert.Nil(t, definitions)
+ })
+ t.Run("invalid JSON", func(t *testing.T) {
+ definitions, err := loadDefinitions("test/invalid_json")
+ assert.ErrorContains(t, err, "unable to parse definition file 'test/invalid_json/1.json'")
+ assert.Nil(t, definitions)
+ })
+ t.Run("invalid definition", func(t *testing.T) {
+ definitions, err := loadDefinitions("test/invalid_definition")
+ assert.ErrorContains(t, err, "unable to parse definition file 'test/invalid_definition/1.json'")
+ assert.Nil(t, definitions)
+ })
+ t.Run("non-existent directory", func(t *testing.T) {
+ definitions, err := loadDefinitions("test/non_existent")
+ assert.ErrorContains(t, err, "unable to read definitions directory 'test/non_existent'")
+ assert.Nil(t, definitions)
+ })
+}
diff --git a/usecase/test/duplicate_id/1.json b/usecase/test/duplicate_id/1.json
new file mode 100644
index 0000000000..533dc08feb
--- /dev/null
+++ b/usecase/test/duplicate_id/1.json
@@ -0,0 +1,49 @@
+{
+ "id": "urn:nuts.nl:usecase:eOverdrachtDev2023",
+ "endpoint": "https://example.com/usecase/eoverdracht_dev",
+ "presentation_max_validity": 36000,
+ "presentation_definition": {
+ "id": "pd_eoverdracht_dev_care_organization",
+ "format": {
+ "ldp_vc": {
+ "proof_type": [
+ "JsonWebSignature2020"
+ ]
+ }
+ },
+ "input_descriptors": [
+ {
+ "id": "id_nuts_care_organization_cred",
+ "constraints": {
+ "fields": [
+ {
+ "path": [
+ "$.type"
+ ],
+ "filter": {
+ "type": "string",
+ "const": "NutsOrganizationCredential"
+ }
+ },
+ {
+ "path": [
+ "$.credentialSubject.organization.name"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ },
+ {
+ "path": [
+ "$.credentialSubject.organization.city"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/usecase/test/duplicate_id/2.json b/usecase/test/duplicate_id/2.json
new file mode 100644
index 0000000000..533dc08feb
--- /dev/null
+++ b/usecase/test/duplicate_id/2.json
@@ -0,0 +1,49 @@
+{
+ "id": "urn:nuts.nl:usecase:eOverdrachtDev2023",
+ "endpoint": "https://example.com/usecase/eoverdracht_dev",
+ "presentation_max_validity": 36000,
+ "presentation_definition": {
+ "id": "pd_eoverdracht_dev_care_organization",
+ "format": {
+ "ldp_vc": {
+ "proof_type": [
+ "JsonWebSignature2020"
+ ]
+ }
+ },
+ "input_descriptors": [
+ {
+ "id": "id_nuts_care_organization_cred",
+ "constraints": {
+ "fields": [
+ {
+ "path": [
+ "$.type"
+ ],
+ "filter": {
+ "type": "string",
+ "const": "NutsOrganizationCredential"
+ }
+ },
+ {
+ "path": [
+ "$.credentialSubject.organization.name"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ },
+ {
+ "path": [
+ "$.credentialSubject.organization.city"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/usecase/test/duplicate_id/README.md b/usecase/test/duplicate_id/README.md
new file mode 100644
index 0000000000..f0fc1802ed
--- /dev/null
+++ b/usecase/test/duplicate_id/README.md
@@ -0,0 +1 @@
+This directory contains an invalid use case definition: 2 definitions have the same ID.
\ No newline at end of file
diff --git a/usecase/test/invalid_definition/1.json b/usecase/test/invalid_definition/1.json
new file mode 100644
index 0000000000..0db3279e44
--- /dev/null
+++ b/usecase/test/invalid_definition/1.json
@@ -0,0 +1,3 @@
+{
+
+}
diff --git a/usecase/test/invalid_definition/README.md b/usecase/test/invalid_definition/README.md
new file mode 100644
index 0000000000..6053c6729b
--- /dev/null
+++ b/usecase/test/invalid_definition/README.md
@@ -0,0 +1 @@
+This directory contains an invalid use case definition: it does not fields that are required according to the JSON schema.
\ No newline at end of file
diff --git a/usecase/test/invalid_json/1.json b/usecase/test/invalid_json/1.json
new file mode 100644
index 0000000000..7e31dc3cad
--- /dev/null
+++ b/usecase/test/invalid_json/1.json
@@ -0,0 +1 @@
+this is not JSON
\ No newline at end of file
diff --git a/usecase/test/invalid_json/README.md b/usecase/test/invalid_json/README.md
new file mode 100644
index 0000000000..30610e3784
--- /dev/null
+++ b/usecase/test/invalid_json/README.md
@@ -0,0 +1 @@
+This directory contains an invalid use case definition: it is not valid JSON.
\ No newline at end of file
diff --git a/usecase/test/valid/eoverdracht.json b/usecase/test/valid/eoverdracht.json
new file mode 100644
index 0000000000..533dc08feb
--- /dev/null
+++ b/usecase/test/valid/eoverdracht.json
@@ -0,0 +1,49 @@
+{
+ "id": "urn:nuts.nl:usecase:eOverdrachtDev2023",
+ "endpoint": "https://example.com/usecase/eoverdracht_dev",
+ "presentation_max_validity": 36000,
+ "presentation_definition": {
+ "id": "pd_eoverdracht_dev_care_organization",
+ "format": {
+ "ldp_vc": {
+ "proof_type": [
+ "JsonWebSignature2020"
+ ]
+ }
+ },
+ "input_descriptors": [
+ {
+ "id": "id_nuts_care_organization_cred",
+ "constraints": {
+ "fields": [
+ {
+ "path": [
+ "$.type"
+ ],
+ "filter": {
+ "type": "string",
+ "const": "NutsOrganizationCredential"
+ }
+ },
+ {
+ "path": [
+ "$.credentialSubject.organization.name"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ },
+ {
+ "path": [
+ "$.credentialSubject.organization.city"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/usecase/test/valid/subdir/README.md b/usecase/test/valid/subdir/README.md
new file mode 100644
index 0000000000..b1778a548c
--- /dev/null
+++ b/usecase/test/valid/subdir/README.md
@@ -0,0 +1 @@
+This directory (with an invalid definition) is there to assert subdirectories are not traversed.
\ No newline at end of file
diff --git a/usecase/test/valid/subdir/empty.json b/usecase/test/valid/subdir/empty.json
new file mode 100644
index 0000000000..2c63c08510
--- /dev/null
+++ b/usecase/test/valid/subdir/empty.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/usecase/usecase-definition-schema.json b/usecase/usecase-definition-schema.json
new file mode 100644
index 0000000000..41484e9ba4
--- /dev/null
+++ b/usecase/usecase-definition-schema.json
@@ -0,0 +1,28 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Use Case Definition",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1
+ },
+ "endpoint": {
+ "type": "string",
+ "minLength": 1
+ },
+ "presentation_max_validity": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "presentation_definition": {
+ "$ref": "http://identity.foundation/presentation-exchange/schemas/presentation-definition.json"
+ }
+ },
+ "required": [
+ "id",
+ "endpoint",
+ "presentation_max_validity",
+ "presentation_definition"
+ ]
+}
\ No newline at end of file
diff --git a/vcr/credential/resolver.go b/vcr/credential/resolver.go
index fd4ec48ac8..7557ddc65c 100644
--- a/vcr/credential/resolver.go
+++ b/vcr/credential/resolver.go
@@ -20,7 +20,10 @@
package credential
import (
+ "errors"
+ "github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
+ "strings"
)
// FindValidator finds the Validator the provided credential based on its Type
@@ -52,3 +55,48 @@ func ExtractTypes(credential vc.VerifiableCredential) []string {
return vcTypes
}
+
+func PresentationSigner(presentation vc.VerifiablePresentation) (*did.DID, error) {
+ switch presentation.Format() {
+ case vc.JWTPresentationProofFormat:
+ token := presentation.JWT()
+ issuer := token.Issuer()
+ if issuer == "" {
+ return nil, errors.New("JWT presentation does not have 'iss' claim")
+ }
+ return did.ParseDID(issuer)
+ default:
+ return nil, errors.New("unsupported presentation format")
+ }
+}
+
+func PresentationSigningKeyID(presentation vc.VerifiablePresentation) (*did.DID, error) {
+ switch presentation.Format() {
+ case vc.JWTPresentationProofFormat:
+ token := presentation.JWT()
+ keyID, exists := token.Get("kid")
+ if !exists {
+ return nil, errors.New("JWT presentation does not have 'kid' claim")
+ }
+ keyIDString, isString := keyID.(string)
+ if !isString {
+ return nil, errors.New("JWT presentation 'kid' claim is not a string")
+ }
+ issuer, err := PresentationSigner(presentation)
+ if err != nil {
+ return nil, err
+ }
+ if strings.HasPrefix(keyIDString, "#") {
+ // Key ID is a fragment, so it's a relative URL to the JWT issuer
+ keyIDString = issuer.String() + keyIDString
+ } else {
+ // Key ID is fully qualified, must be prefixed with JWT issuer
+ if !strings.HasPrefix(keyIDString, issuer.String()+"#") {
+ return nil, errors.New("JWT presentation 'kid' claim must be scoped to 'iss' claim if absolute")
+ }
+ }
+ return did.ParseDIDURL(keyIDString)
+ default:
+ return nil, errors.New("unsupported presentation format")
+ }
+}
diff --git a/vcr/pe/schema/v2/schema.go b/vcr/pe/schema/v2/schema.go
index 44de55fdd8..8c3da1a2bc 100644
--- a/vcr/pe/schema/v2/schema.go
+++ b/vcr/pe/schema/v2/schema.go
@@ -51,19 +51,21 @@ var PresentationDefinition *jsonschema.Schema
// PresentationSubmission is the JSON schema for a presentation submission.
var PresentationSubmission *jsonschema.Schema
+// Compiler is the JSON schema compiler.
+var Compiler = jsonschema.NewCompiler()
+
func init() {
// By default, it loads from filesystem, but that sounds unsafe.
// Since register our schemas, we don't need to allow loading resources.
loader.Load = func(url string) (io.ReadCloser, error) {
return nil, fmt.Errorf("refusing to load unknown schema: %s", url)
}
- compiler := jsonschema.NewCompiler()
- compiler.Draft = jsonschema.Draft7
- if err := loadSchemas(schemaFiles, compiler); err != nil {
+ Compiler.Draft = jsonschema.Draft7
+ if err := loadSchemas(schemaFiles, Compiler); err != nil {
panic(err)
}
- PresentationDefinition = compiler.MustCompile(presentationDefinition)
- PresentationSubmission = compiler.MustCompile(presentationSubmission)
+ PresentationDefinition = Compiler.MustCompile(presentationDefinition)
+ PresentationSubmission = Compiler.MustCompile(presentationSubmission)
}
func loadSchemas(reader fs.ReadFileFS, compiler *jsonschema.Compiler) error {
diff --git a/vcr/pe/test/definition_mapping.json b/vcr/pe/test/definition_mapping.json
index b543faa577..5459a3ea65 100644
--- a/vcr/pe/test/definition_mapping.json
+++ b/vcr/pe/test/definition_mapping.json
@@ -20,6 +20,16 @@
"const": "NutsOrganizationCredential"
}
},
+ {
+ "path": ["$.issuer"],
+ "filter": {
+ "type": "string",
+ "filter": {
+ "type": "string",
+ "pattern": "^did:example:123456789abcdefghi$"
+ }
+ }
+ },
{
"path": ["$.credentialSubject.organization.name"],
"filter": {