@sphereon/ssi-sdk.linked-vp 0.34.1-feature.SSISDK.82.linkedVP.325
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/index.cjs +463 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +447 -0
- package/dist/index.d.ts +447 -0
- package/dist/index.js +442 -0
- package/dist/index.js.map +1 -0
- package/package.json +80 -0
- package/plugin.schema.json +183 -0
- package/src/__tests__/localAgent.test.ts +95 -0
- package/src/__tests__/shared/linkedVPManagerAgentLogic.ts +170 -0
- package/src/agent/LinkedVPManager.ts +221 -0
- package/src/index.ts +7 -0
- package/src/services/LinkedVPService.ts +84 -0
- package/src/types/ILinkedVPManager.ts +85 -0
- package/src/types/index.ts +1 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ILinkedVPManager": {
|
|
3
|
+
"components": {
|
|
4
|
+
"schemas": {
|
|
5
|
+
"GeneratePresentationArgs": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"linkedVpId": {
|
|
9
|
+
"type": "string"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"required": [
|
|
13
|
+
"linkedVpId"
|
|
14
|
+
],
|
|
15
|
+
"additionalProperties": false
|
|
16
|
+
},
|
|
17
|
+
"LinkedVPPresentation": {
|
|
18
|
+
"anyOf": [
|
|
19
|
+
{
|
|
20
|
+
"type": "string"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"$ref": "#/components/schemas/Record<string,any>"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"Record<string,any>": {
|
|
28
|
+
"type": "object"
|
|
29
|
+
},
|
|
30
|
+
"GetServiceEntriesArgs": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"properties": {
|
|
33
|
+
"tenantId": {
|
|
34
|
+
"type": "string"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"additionalProperties": false
|
|
38
|
+
},
|
|
39
|
+
"LinkedVPServiceEntry": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {
|
|
42
|
+
"id": {
|
|
43
|
+
"type": "string"
|
|
44
|
+
},
|
|
45
|
+
"type": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"const": "LinkedVerifiablePresentation"
|
|
48
|
+
},
|
|
49
|
+
"serviceEndpoint": {
|
|
50
|
+
"type": "string"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"required": [
|
|
54
|
+
"id",
|
|
55
|
+
"type",
|
|
56
|
+
"serviceEndpoint"
|
|
57
|
+
],
|
|
58
|
+
"additionalProperties": false
|
|
59
|
+
},
|
|
60
|
+
"HasLinkedVPEntryArgs": {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {
|
|
63
|
+
"linkedVpId": {
|
|
64
|
+
"type": "string"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"required": [
|
|
68
|
+
"linkedVpId"
|
|
69
|
+
],
|
|
70
|
+
"additionalProperties": false
|
|
71
|
+
},
|
|
72
|
+
"PublishCredentialArgs": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"properties": {
|
|
75
|
+
"digitalCredentialId": {
|
|
76
|
+
"type": "string"
|
|
77
|
+
},
|
|
78
|
+
"linkedVpId": {
|
|
79
|
+
"type": "string"
|
|
80
|
+
},
|
|
81
|
+
"tenantId": {
|
|
82
|
+
"type": "string"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"required": [
|
|
86
|
+
"digitalCredentialId"
|
|
87
|
+
],
|
|
88
|
+
"additionalProperties": false
|
|
89
|
+
},
|
|
90
|
+
"LinkedVPEntry": {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"properties": {
|
|
93
|
+
"id": {
|
|
94
|
+
"type": "string"
|
|
95
|
+
},
|
|
96
|
+
"linkedVpId": {
|
|
97
|
+
"type": "string"
|
|
98
|
+
},
|
|
99
|
+
"tenantId": {
|
|
100
|
+
"type": "string"
|
|
101
|
+
},
|
|
102
|
+
"linkedVpFrom": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"format": "date-time"
|
|
105
|
+
},
|
|
106
|
+
"createdAt": {
|
|
107
|
+
"type": "string",
|
|
108
|
+
"format": "date-time"
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"required": [
|
|
112
|
+
"id",
|
|
113
|
+
"linkedVpId",
|
|
114
|
+
"createdAt"
|
|
115
|
+
],
|
|
116
|
+
"additionalProperties": false
|
|
117
|
+
},
|
|
118
|
+
"UnpublishCredentialArgs": {
|
|
119
|
+
"type": "object",
|
|
120
|
+
"properties": {
|
|
121
|
+
"linkedVpId": {
|
|
122
|
+
"type": "string"
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
"required": [
|
|
126
|
+
"linkedVpId"
|
|
127
|
+
],
|
|
128
|
+
"additionalProperties": false
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"methods": {
|
|
132
|
+
"lvpGeneratePresentation": {
|
|
133
|
+
"description": "Generate and return a Verifiable Presentation for a published LinkedVP This is the main endpoint handler for GET /linked-vp/",
|
|
134
|
+
"arguments": {
|
|
135
|
+
"$ref": "#/components/schemas/GeneratePresentationArgs"
|
|
136
|
+
},
|
|
137
|
+
"returnType": {
|
|
138
|
+
"$ref": "#/components/schemas/LinkedVPPresentation"
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
"lvpGetServiceEntries": {
|
|
142
|
+
"description": "Get LinkedVP service entries for a DID to be added to a DID Document This is useful when generating DID Documents with toDidDocument",
|
|
143
|
+
"arguments": {
|
|
144
|
+
"$ref": "#/components/schemas/GetServiceEntriesArgs"
|
|
145
|
+
},
|
|
146
|
+
"returnType": {
|
|
147
|
+
"type": "array",
|
|
148
|
+
"items": {
|
|
149
|
+
"$ref": "#/components/schemas/LinkedVPServiceEntry"
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"lvpHasEntry": {
|
|
154
|
+
"description": "Check if a LinkedVP entry exists by linkedVpId",
|
|
155
|
+
"arguments": {
|
|
156
|
+
"$ref": "#/components/schemas/HasLinkedVPEntryArgs"
|
|
157
|
+
},
|
|
158
|
+
"returnType": {
|
|
159
|
+
"type": "boolean"
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
"lvpPublishCredential": {
|
|
163
|
+
"description": "Publish a credential as a LinkedVP by adding it to the holder's DID Document",
|
|
164
|
+
"arguments": {
|
|
165
|
+
"$ref": "#/components/schemas/PublishCredentialArgs"
|
|
166
|
+
},
|
|
167
|
+
"returnType": {
|
|
168
|
+
"$ref": "#/components/schemas/LinkedVPEntry"
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"lvpUnpublishCredential": {
|
|
172
|
+
"description": "Unpublish a credential by removing its LinkedVP entry from the DID Document",
|
|
173
|
+
"arguments": {
|
|
174
|
+
"$ref": "#/components/schemas/UnpublishCredentialArgs"
|
|
175
|
+
},
|
|
176
|
+
"returnType": {
|
|
177
|
+
"type": "boolean"
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { IdentifierResolution } from '@sphereon/ssi-sdk-ext.identifier-resolution'
|
|
2
|
+
import { SphereonKeyManager } from '@sphereon/ssi-sdk-ext.key-manager'
|
|
3
|
+
import { SphereonKeyManagementSystem } from '@sphereon/ssi-sdk-ext.kms-local'
|
|
4
|
+
import { CredentialStore } from '@sphereon/ssi-sdk.credential-store'
|
|
5
|
+
import { VcdmCredentialPlugin } from '@sphereon/ssi-sdk.credential-vcdm'
|
|
6
|
+
import { CredentialProviderJWT } from '@sphereon/ssi-sdk.credential-vcdm1-jwt-provider'
|
|
7
|
+
|
|
8
|
+
import { DigitalCredentialEntity, DigitalCredentialStore } from '@sphereon/ssi-sdk.data-store'
|
|
9
|
+
|
|
10
|
+
import { Agent } from '@veramo/core'
|
|
11
|
+
import { DIDManager, MemoryDIDStore } from '@veramo/did-manager'
|
|
12
|
+
import { WebDIDProvider } from '@veramo/did-provider-web'
|
|
13
|
+
import { MemoryKeyStore, MemoryPrivateKeyStore } from '@veramo/key-manager'
|
|
14
|
+
import { DataSource } from 'typeorm'
|
|
15
|
+
import { describe } from 'vitest'
|
|
16
|
+
import { LinkedVPManager } from '../agent/LinkedVPManager'
|
|
17
|
+
import linkedVPManagerAgentLogic from './shared/linkedVPManagerAgentLogic'
|
|
18
|
+
|
|
19
|
+
let agent: any
|
|
20
|
+
|
|
21
|
+
const setup = async () => {
|
|
22
|
+
const db = new DataSource({
|
|
23
|
+
type: 'sqlite',
|
|
24
|
+
database: ':memory:',
|
|
25
|
+
synchronize: true,
|
|
26
|
+
entities: [DigitalCredentialEntity],
|
|
27
|
+
})
|
|
28
|
+
await db.initialize()
|
|
29
|
+
|
|
30
|
+
const digitalStore = new DigitalCredentialStore(db)
|
|
31
|
+
const jwt = new CredentialProviderJWT()
|
|
32
|
+
|
|
33
|
+
const plugins = [
|
|
34
|
+
new CredentialStore({ store: digitalStore }),
|
|
35
|
+
|
|
36
|
+
new SphereonKeyManager({
|
|
37
|
+
store: new MemoryKeyStore(),
|
|
38
|
+
kms: {
|
|
39
|
+
local: new SphereonKeyManagementSystem(new MemoryPrivateKeyStore()),
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
|
|
43
|
+
new DIDManager({
|
|
44
|
+
store: new MemoryDIDStore(),
|
|
45
|
+
defaultProvider: 'did:web',
|
|
46
|
+
providers: {
|
|
47
|
+
[`did:web`]: new WebDIDProvider({
|
|
48
|
+
defaultKms: 'local',
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
|
|
53
|
+
new IdentifierResolution(),
|
|
54
|
+
|
|
55
|
+
new LinkedVPManager({
|
|
56
|
+
holderDids: {
|
|
57
|
+
default: 'did:web:example.com',
|
|
58
|
+
tenant1: 'did:web:example.com:tenants:tenant1',
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
|
|
62
|
+
new VcdmCredentialPlugin({ issuers: [jwt] }),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
agent = new Agent({
|
|
66
|
+
plugins,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// Create tenant DIDs
|
|
70
|
+
await agent.didManagerImport({
|
|
71
|
+
did: 'did:web:example.com:tenants:tenant1',
|
|
72
|
+
provider: 'did:web',
|
|
73
|
+
keys: [
|
|
74
|
+
{
|
|
75
|
+
kid: 'key-1',
|
|
76
|
+
type: 'Secp256r1',
|
|
77
|
+
privateKeyHex:
|
|
78
|
+
'078c0f0eaa6510fab9f4f2cf8657b32811c53d7d98869fd0d5bd08a7ba34376b8adfdd44784dea407e088ff2437d5e2123e685a26dca91efceb7a9f4dfd81848',
|
|
79
|
+
publicKeyHex: '8adfdd44784dea407e088ff2437d5e2123e685a26dca91efceb7a9f4dfd81848',
|
|
80
|
+
kms: 'local',
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const tearDown = async () => true
|
|
89
|
+
const getAgent = () => agent
|
|
90
|
+
|
|
91
|
+
const testContext = { getAgent, setup, tearDown, isRestTest: false }
|
|
92
|
+
|
|
93
|
+
describe('LinkedVP Manager Local integration tests', () => {
|
|
94
|
+
linkedVPManagerAgentLogic(testContext)
|
|
95
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { CredentialRole } from '@sphereon/ssi-types'
|
|
2
|
+
import { TAgent } from '@veramo/core'
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
4
|
+
import { ILinkedVPManager } from '../../index'
|
|
5
|
+
|
|
6
|
+
type ConfiguredAgent = TAgent<ILinkedVPManager>
|
|
7
|
+
|
|
8
|
+
const holderDid = 'did:web:example.com'
|
|
9
|
+
const tenantId = 'tenant1'
|
|
10
|
+
const holderDidWithTenant = 'did:web:example.com:tenants:tenant1'
|
|
11
|
+
|
|
12
|
+
function createMockVC(typeSuffix: string) {
|
|
13
|
+
return {
|
|
14
|
+
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
15
|
+
type: ['VerifiableCredential', typeSuffix],
|
|
16
|
+
issuer: 'did:web:issuer.com',
|
|
17
|
+
issuanceDate: new Date().toISOString(),
|
|
18
|
+
credentialSubject: {
|
|
19
|
+
id: holderDid,
|
|
20
|
+
value: `value-${Math.random().toString(36).slice(2)}`,
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function createTestCredential(agent: ConfiguredAgent, tenantId: string) {
|
|
26
|
+
const mockVC = createMockVC('TestCredential')
|
|
27
|
+
|
|
28
|
+
const created = await agent.crsAddCredential({
|
|
29
|
+
credential: {
|
|
30
|
+
credentialRole: CredentialRole.HOLDER,
|
|
31
|
+
rawDocument: JSON.stringify(mockVC),
|
|
32
|
+
issuerCorrelationType: 'DID' as any,
|
|
33
|
+
issuerCorrelationId: 'did:web:issuer.com',
|
|
34
|
+
kmsKeyRef: 'mock-key-ref',
|
|
35
|
+
identifierMethod: 'did:web',
|
|
36
|
+
tenantId,
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
return created.id
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default (testContext: {
|
|
44
|
+
getAgent: () => ConfiguredAgent
|
|
45
|
+
setup: () => Promise<boolean>
|
|
46
|
+
tearDown: () => Promise<boolean>
|
|
47
|
+
isRestTest: boolean
|
|
48
|
+
}): void => {
|
|
49
|
+
describe('LinkedVP Manager Agent Plugin', (): void => {
|
|
50
|
+
let agent: ConfiguredAgent
|
|
51
|
+
|
|
52
|
+
beforeAll(async (): Promise<void> => {
|
|
53
|
+
await testContext.setup()
|
|
54
|
+
agent = testContext.getAgent()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterAll(testContext.tearDown)
|
|
58
|
+
|
|
59
|
+
it('should publish credential with auto-generated linkedVpId INCLUDING tenant suffix', async () => {
|
|
60
|
+
const credentialId = await createTestCredential(agent, tenantId)
|
|
61
|
+
|
|
62
|
+
const result = await agent.lvpPublishCredential({
|
|
63
|
+
digitalCredentialId: credentialId,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(result.linkedVpId).toMatch(/^lvp-\d+-[a-z0-9]+@tenant1$/)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should publish credential with custom linkedVpId AND append tenantId', async () => {
|
|
70
|
+
const credentialId = await createTestCredential(agent, tenantId)
|
|
71
|
+
const customLinkedVpId = 'my-custom-lvp-id'
|
|
72
|
+
|
|
73
|
+
const result = await agent.lvpPublishCredential({
|
|
74
|
+
digitalCredentialId: credentialId,
|
|
75
|
+
linkedVpId: customLinkedVpId,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(result.linkedVpId).toBe('my-custom-lvp-id@tenant1')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should fail to publish already published credential', async () => {
|
|
82
|
+
const credentialId = await createTestCredential(agent, tenantId)
|
|
83
|
+
const linkedVpId = 'already-published'
|
|
84
|
+
|
|
85
|
+
await agent.lvpPublishCredential({
|
|
86
|
+
digitalCredentialId: credentialId,
|
|
87
|
+
linkedVpId,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
await expect(
|
|
91
|
+
agent.lvpPublishCredential({
|
|
92
|
+
digitalCredentialId: credentialId,
|
|
93
|
+
linkedVpId: 'different-id',
|
|
94
|
+
}),
|
|
95
|
+
).rejects.toThrow(/already published/)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should fail to publish duplicate linkedVpId (after tenant appending)', async () => {
|
|
99
|
+
const cred1 = await createTestCredential(agent, tenantId)
|
|
100
|
+
const cred2 = await createTestCredential(agent, tenantId)
|
|
101
|
+
const duplicateId = 'duplicate-lvp-id'
|
|
102
|
+
|
|
103
|
+
await agent.lvpPublishCredential({ digitalCredentialId: cred1, linkedVpId: duplicateId })
|
|
104
|
+
|
|
105
|
+
await expect(
|
|
106
|
+
agent.lvpPublishCredential({
|
|
107
|
+
digitalCredentialId: cred2,
|
|
108
|
+
linkedVpId: duplicateId,
|
|
109
|
+
}),
|
|
110
|
+
).rejects.toThrow(/already exists/)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should unpublish a credential', async () => {
|
|
114
|
+
const credentialId = await createTestCredential(agent, tenantId)
|
|
115
|
+
const linkedVpId = 'to-be-unpublished'
|
|
116
|
+
|
|
117
|
+
await agent.lvpPublishCredential({
|
|
118
|
+
digitalCredentialId: credentialId,
|
|
119
|
+
linkedVpId,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const result = await agent.lvpUnpublishCredential({
|
|
123
|
+
linkedVpId: `${linkedVpId}@tenant1`,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
expect(result).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should get service entries for default tenant (no tenantId param)', async () => {
|
|
130
|
+
const id1 = await createTestCredential(agent, tenantId)
|
|
131
|
+
const id2 = await createTestCredential(agent, tenantId)
|
|
132
|
+
|
|
133
|
+
await agent.lvpPublishCredential({ digitalCredentialId: id1, linkedVpId: 'service1' })
|
|
134
|
+
await agent.lvpPublishCredential({ digitalCredentialId: id2, linkedVpId: 'service2' })
|
|
135
|
+
|
|
136
|
+
const entries = await agent.lvpGetServiceEntries({})
|
|
137
|
+
|
|
138
|
+
expect(entries).toEqual(
|
|
139
|
+
expect.arrayContaining([
|
|
140
|
+
{
|
|
141
|
+
id: `${holderDidWithTenant}#service1@tenant1`,
|
|
142
|
+
type: 'LinkedVerifiablePresentation',
|
|
143
|
+
serviceEndpoint: `https://example.com/tenants/tenant1/linked-vp/service1@tenant1`,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: `${holderDidWithTenant}#service2@tenant1`,
|
|
147
|
+
type: 'LinkedVerifiablePresentation',
|
|
148
|
+
serviceEndpoint: `https://example.com/tenants/tenant1/linked-vp/service2@tenant1`,
|
|
149
|
+
},
|
|
150
|
+
]),
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
/* TODO, did:web may be complicated in a test
|
|
155
|
+
it('should generate presentation for published credentials', async () => {
|
|
156
|
+
const credentialId = await createTestCredential(agent, tenantId)
|
|
157
|
+
const baseId = 'presentation-test'
|
|
158
|
+
|
|
159
|
+
await agent.lvpPublishCredential({
|
|
160
|
+
digitalCredentialId: credentialId,
|
|
161
|
+
linkedVpId: baseId,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const fullLinkedVpId = `${baseId}@tenant1`
|
|
165
|
+
|
|
166
|
+
const vp = await agent.lvpGeneratePresentation({ linkedVpId: fullLinkedVpId })
|
|
167
|
+
expect(vp).toBeDefined()
|
|
168
|
+
})*/
|
|
169
|
+
})
|
|
170
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { DigitalCredential } from '@sphereon/ssi-sdk.data-store-types'
|
|
2
|
+
import { IAgentPlugin } from '@veramo/core'
|
|
3
|
+
import { IsNull, Not } from 'typeorm'
|
|
4
|
+
import { schema } from '../index'
|
|
5
|
+
import { createLinkedVPPresentation } from '../services/LinkedVPService'
|
|
6
|
+
import {
|
|
7
|
+
GeneratePresentationArgs,
|
|
8
|
+
GetServiceEntriesArgs,
|
|
9
|
+
HasLinkedVPEntryArgs,
|
|
10
|
+
ILinkedVPManager,
|
|
11
|
+
LinkedVPEntry,
|
|
12
|
+
LinkedVPPresentation,
|
|
13
|
+
LinkedVPServiceEntry,
|
|
14
|
+
PublishCredentialArgs,
|
|
15
|
+
RequiredContext,
|
|
16
|
+
UnpublishCredentialArgs,
|
|
17
|
+
} from '../types'
|
|
18
|
+
|
|
19
|
+
// Exposing the methods here for any REST implementation
|
|
20
|
+
export const linkedVPManagerMethods: Array<string> = [
|
|
21
|
+
'lvpPublishCredential',
|
|
22
|
+
'lvpUnpublishCredential',
|
|
23
|
+
'lvpHasEntry',
|
|
24
|
+
'lvpGetServiceEntries',
|
|
25
|
+
'lvpGeneratePresentation',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* {@inheritDoc ILinkedVPManager}
|
|
30
|
+
*/
|
|
31
|
+
export class LinkedVPManager implements IAgentPlugin {
|
|
32
|
+
readonly schema = schema.ILinkedVPManager
|
|
33
|
+
readonly methods: ILinkedVPManager = {
|
|
34
|
+
lvpPublishCredential: this.lvpPublishCredential.bind(this),
|
|
35
|
+
lvpUnpublishCredential: this.lvpUnpublishCredential.bind(this),
|
|
36
|
+
lvpHasEntry: this.lvpHasEntry.bind(this),
|
|
37
|
+
lvpGetServiceEntries: this.lvpGetServiceEntries.bind(this),
|
|
38
|
+
lvpGeneratePresentation: this.lvpGeneratePresentation.bind(this),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private readonly holderDids: Record<string, string>
|
|
42
|
+
|
|
43
|
+
constructor(options: { holderDids: Record<string, string> }) {
|
|
44
|
+
this.holderDids = options.holderDids
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async lvpPublishCredential(args: PublishCredentialArgs, context: RequiredContext): Promise<LinkedVPEntry> {
|
|
48
|
+
const { digitalCredentialId } = args
|
|
49
|
+
|
|
50
|
+
const credential: DigitalCredential = await context.agent.crsGetCredential({ id: digitalCredentialId })
|
|
51
|
+
|
|
52
|
+
if (credential.linkedVpId) {
|
|
53
|
+
return Promise.reject(new Error(`Credential ${digitalCredentialId} is already published with linkedVpId ${credential.linkedVpId}`))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const linkedVpId = this.buildLinkedVpId(args.linkedVpId, credential.tenantId)
|
|
57
|
+
|
|
58
|
+
await this.ensureLinkedVpIdUnique(linkedVpId, context, credential.tenantId)
|
|
59
|
+
|
|
60
|
+
const publishedAt = new Date()
|
|
61
|
+
await context.agent.crsUpdateCredential({
|
|
62
|
+
id: digitalCredentialId,
|
|
63
|
+
linkedVpId,
|
|
64
|
+
linkedVpFrom: publishedAt,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
id: credential.id,
|
|
69
|
+
linkedVpId,
|
|
70
|
+
tenantId: credential.tenantId,
|
|
71
|
+
linkedVpFrom: publishedAt,
|
|
72
|
+
createdAt: credential.createdAt,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async lvpUnpublishCredential(args: UnpublishCredentialArgs, context: RequiredContext): Promise<boolean> {
|
|
77
|
+
const { linkedVpId } = args
|
|
78
|
+
|
|
79
|
+
// Find credential by linkedVpId and tenantId
|
|
80
|
+
const credentials = await context.agent.crsGetCredentials({
|
|
81
|
+
filter: [{ linkedVpId }],
|
|
82
|
+
})
|
|
83
|
+
if (credentials.length === 0) {
|
|
84
|
+
return Promise.reject(Error(`No credential found with linkedVpId ${linkedVpId}`))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const credential = credentials[0]
|
|
88
|
+
await context.agent.crsUpdateCredential({
|
|
89
|
+
id: credential.id,
|
|
90
|
+
linkedVpId: undefined,
|
|
91
|
+
linkedVpFrom: undefined,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async lvpHasEntry(args: HasLinkedVPEntryArgs, context: RequiredContext): Promise<boolean> {
|
|
98
|
+
const { linkedVpId } = args
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const credentials = await context.agent.crsGetCredentials({
|
|
102
|
+
filter: [{ linkedVpId }],
|
|
103
|
+
})
|
|
104
|
+
return credentials.length > 0
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async lvpGetServiceEntries(args: GetServiceEntriesArgs, context: RequiredContext): Promise<Array<LinkedVPServiceEntry>> {
|
|
111
|
+
const { tenantId } = args
|
|
112
|
+
|
|
113
|
+
// Get all published credentials (credentials with linkedVpId set)
|
|
114
|
+
const filter: any = { linkedVpId: Not(IsNull()) }
|
|
115
|
+
if (tenantId) {
|
|
116
|
+
filter.tenantId = tenantId
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const credentials = await context.agent.crsGetCredentials({
|
|
120
|
+
filter: [filter],
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
return credentials
|
|
124
|
+
.filter((cred) => cred.linkedVpId !== undefined && cred.linkedVpId !== null)
|
|
125
|
+
.map((cred) => {
|
|
126
|
+
const holderDidForEntry = this.getHolderDid(cred.tenantId)
|
|
127
|
+
return this.credentialToServiceEntry(cred, holderDidForEntry)
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async lvpGeneratePresentation(args: GeneratePresentationArgs, context: RequiredContext): Promise<LinkedVPPresentation> {
|
|
132
|
+
const { linkedVpId } = args
|
|
133
|
+
const tenantId = this.parseTenantFromLinkedVpId(linkedVpId)
|
|
134
|
+
const holderDid = this.getHolderDid(tenantId)
|
|
135
|
+
|
|
136
|
+
const uniqueCredentials = await context.agent.crsGetUniqueCredentials({
|
|
137
|
+
filter: [
|
|
138
|
+
{
|
|
139
|
+
linkedVpId: args.linkedVpId,
|
|
140
|
+
...(tenantId && { tenantId }),
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
})
|
|
144
|
+
if (uniqueCredentials.length === 0) {
|
|
145
|
+
return Promise.reject(Error(`No published credentials found for linkedVpId ${linkedVpId}`))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Generate the Verifiable Presentation with all published credentials
|
|
149
|
+
return createLinkedVPPresentation(holderDid, uniqueCredentials, context.agent)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private getHolderDid(tenantId: string | undefined) {
|
|
153
|
+
const holderDid = this.holderDids[tenantId ?? 'default']
|
|
154
|
+
if (!holderDid) {
|
|
155
|
+
throw Error(`No holder did supplied for tenant ${tenantId ?? 'default'}`)
|
|
156
|
+
}
|
|
157
|
+
return holderDid
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private parseTenantFromLinkedVpId(linkedVpId: string): string | undefined {
|
|
161
|
+
const idx = linkedVpId.lastIndexOf('@')
|
|
162
|
+
return idx === -1 ? undefined : linkedVpId.substring(idx + 1)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private generateLinkedVpId(): string {
|
|
166
|
+
return `lvp-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async ensureLinkedVpIdUnique(linkedVpId: string, context: RequiredContext, tenantId?: string): Promise<void> {
|
|
170
|
+
const credentials = await context.agent.crsGetCredentials({
|
|
171
|
+
filter: [{ linkedVpId, ...(tenantId && { tenantId }) }],
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
if (credentials.length > 0) {
|
|
175
|
+
throw new Error(`LinkedVP ID ${linkedVpId} already exists${tenantId ? ` for tenant ${tenantId}` : ''}`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private buildLinkedVpId(linkedVpId: string | undefined, tenantId: string | undefined) {
|
|
180
|
+
let finalLinkedVpId = linkedVpId || this.generateLinkedVpId()
|
|
181
|
+
|
|
182
|
+
// Append tenantId if provided and not already present
|
|
183
|
+
if (tenantId && tenantId !== '' && !finalLinkedVpId.includes('@')) {
|
|
184
|
+
finalLinkedVpId = `${finalLinkedVpId}@${tenantId}`
|
|
185
|
+
}
|
|
186
|
+
return finalLinkedVpId
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private getBaseUrlFromDid(holderDid: string): string {
|
|
190
|
+
if (!holderDid.startsWith('did:web:')) {
|
|
191
|
+
throw new Error(`Invalid DID: ${holderDid}, must be did:web`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const withoutPrefix = holderDid.replace('did:web:', '') // example.com:tenants:tenant1
|
|
195
|
+
const parts = withoutPrefix.split(':')
|
|
196
|
+
const domain = parts.shift()! // example.com
|
|
197
|
+
const path = parts.join('/') // tenants/tenant1
|
|
198
|
+
|
|
199
|
+
return path
|
|
200
|
+
? `https://${domain}/${path}` // https://example.com/tenants/tenant1
|
|
201
|
+
: `https://${domain}` // https://example.com
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private buildServiceEndpoint(holderDid: string, linkedVpId: string): string {
|
|
205
|
+
const baseUrl = this.getBaseUrlFromDid(holderDid)
|
|
206
|
+
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
|
|
207
|
+
return `${cleanBaseUrl}/linked-vp/${linkedVpId}`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private credentialToServiceEntry(credential: DigitalCredential, holderDid: string): LinkedVPServiceEntry {
|
|
211
|
+
if (!credential.linkedVpId) {
|
|
212
|
+
throw new Error(`Credential ${credential.id} does not have a linkedVpId`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
id: `${holderDid}#${credential.linkedVpId}`,
|
|
217
|
+
type: 'LinkedVerifiablePresentation',
|
|
218
|
+
serviceEndpoint: this.buildServiceEndpoint(holderDid, credential.linkedVpId),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|