@sphereon/ssi-sdk.linked-vp 0.34.1-feature.SSISDK.82.and.SSISDK.70.345
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 +535 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +448 -0
- package/dist/index.d.ts +448 -0
- package/dist/index.js +514 -0
- package/dist/index.js.map +1 -0
- package/package.json +81 -0
- package/plugin.schema.json +183 -0
- package/src/__tests__/localAgent.test.ts +95 -0
- package/src/__tests__/shared/linkedVPManagerAgentLogic.ts +180 -0
- package/src/agent/LinkedVPManager.ts +240 -0
- package/src/index.ts +7 -0
- package/src/services/LinkedVPService.ts +150 -0
- package/src/types/ILinkedVPManager.ts +90 -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,180 @@
|
|
|
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-[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
|
+
// Clean up any existing published credentials first
|
|
131
|
+
const existingEntries = await agent.lvpGetServiceEntries({})
|
|
132
|
+
for (const entry of existingEntries) {
|
|
133
|
+
const linkedVpId = entry.id.split('#')[1]
|
|
134
|
+
await agent.lvpUnpublishCredential({ linkedVpId }).catch(() => {
|
|
135
|
+
// Ignore errors if already unpublished
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const id1 = await createTestCredential(agent, tenantId)
|
|
140
|
+
const id2 = await createTestCredential(agent, tenantId)
|
|
141
|
+
|
|
142
|
+
await agent.lvpPublishCredential({ digitalCredentialId: id1, linkedVpId: 'service1' })
|
|
143
|
+
await agent.lvpPublishCredential({ digitalCredentialId: id2, linkedVpId: 'service2' })
|
|
144
|
+
|
|
145
|
+
const entries = await agent.lvpGetServiceEntries({})
|
|
146
|
+
|
|
147
|
+
expect(entries).toHaveLength(2)
|
|
148
|
+
expect(entries).toEqual(
|
|
149
|
+
expect.arrayContaining([
|
|
150
|
+
{
|
|
151
|
+
id: `${holderDid}#service1@tenant1`,
|
|
152
|
+
type: 'LinkedVerifiablePresentation',
|
|
153
|
+
serviceEndpoint: `https://example.com/linked-vp/service1@tenant1`,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: `${holderDid}#service2@tenant1`,
|
|
157
|
+
type: 'LinkedVerifiablePresentation',
|
|
158
|
+
serviceEndpoint: `https://example.com/linked-vp/service2@tenant1`,
|
|
159
|
+
},
|
|
160
|
+
]),
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
/* TODO, did:web may be complicated in a test
|
|
165
|
+
it('should generate presentation for published credentials', async () => {
|
|
166
|
+
const credentialId = await createTestCredential(agent, tenantId)
|
|
167
|
+
const baseId = 'presentation-test'
|
|
168
|
+
|
|
169
|
+
await agent.lvpPublishCredential({
|
|
170
|
+
digitalCredentialId: credentialId,
|
|
171
|
+
linkedVpId: baseId,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const fullLinkedVpId = `${baseId}@tenant1`
|
|
175
|
+
|
|
176
|
+
const vp = await agent.lvpGeneratePresentation({ linkedVpId: fullLinkedVpId })
|
|
177
|
+
expect(vp).toBeDefined()
|
|
178
|
+
})*/
|
|
179
|
+
})
|
|
180
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { DigitalCredential } from '@sphereon/ssi-sdk.data-store-types'
|
|
2
|
+
import { type IVerifiableCredential } from '@sphereon/ssi-types'
|
|
3
|
+
import { IAgentPlugin } from '@veramo/core'
|
|
4
|
+
import { IsNull, Not } from 'typeorm'
|
|
5
|
+
import { schema } from '../index'
|
|
6
|
+
import { createLinkedVPPresentation } from '../services/LinkedVPService'
|
|
7
|
+
import {
|
|
8
|
+
GeneratePresentationArgs,
|
|
9
|
+
GetServiceEntriesArgs,
|
|
10
|
+
HasLinkedVPEntryArgs,
|
|
11
|
+
ILinkedVPManager,
|
|
12
|
+
LinkedVPEntry,
|
|
13
|
+
LinkedVPPresentation,
|
|
14
|
+
LinkedVPServiceEntry,
|
|
15
|
+
PublishCredentialArgs,
|
|
16
|
+
RequiredContext,
|
|
17
|
+
UnpublishCredentialArgs,
|
|
18
|
+
} from '../types'
|
|
19
|
+
|
|
20
|
+
// Exposing the methods here for any REST implementation
|
|
21
|
+
export const linkedVPManagerMethods: Array<string> = [
|
|
22
|
+
'lvpPublishCredential',
|
|
23
|
+
'lvpUnpublishCredential',
|
|
24
|
+
'lvpHasEntry',
|
|
25
|
+
'lvpGetServiceEntries',
|
|
26
|
+
'lvpGeneratePresentation',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* {@inheritDoc ILinkedVPManager}
|
|
31
|
+
*/
|
|
32
|
+
export class LinkedVPManager implements IAgentPlugin {
|
|
33
|
+
readonly schema = schema.ILinkedVPManager
|
|
34
|
+
readonly methods: ILinkedVPManager = {
|
|
35
|
+
lvpPublishCredential: this.lvpPublishCredential.bind(this),
|
|
36
|
+
lvpUnpublishCredential: this.lvpUnpublishCredential.bind(this),
|
|
37
|
+
lvpHasEntry: this.lvpHasEntry.bind(this),
|
|
38
|
+
lvpGetServiceEntries: this.lvpGetServiceEntries.bind(this),
|
|
39
|
+
lvpGeneratePresentation: this.lvpGeneratePresentation.bind(this),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async lvpPublishCredential(args: PublishCredentialArgs, context: RequiredContext): Promise<LinkedVPEntry> {
|
|
43
|
+
const { digitalCredentialId } = args
|
|
44
|
+
|
|
45
|
+
const credential: DigitalCredential = await context.agent.crsGetCredential({ id: digitalCredentialId })
|
|
46
|
+
|
|
47
|
+
if (credential.linkedVpId) {
|
|
48
|
+
return Promise.reject(new Error(`Credential ${digitalCredentialId} is already published with linkedVpId ${credential.linkedVpId}`))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const linkedVpId = this.buildLinkedVpId(args.linkedVpId, credential.tenantId)
|
|
52
|
+
|
|
53
|
+
await this.ensureLinkedVpIdUnique(linkedVpId, context, credential.tenantId)
|
|
54
|
+
|
|
55
|
+
const publishedAt = new Date()
|
|
56
|
+
await context.agent.crsUpdateCredential({
|
|
57
|
+
id: digitalCredentialId,
|
|
58
|
+
linkedVpId,
|
|
59
|
+
linkedVpFrom: publishedAt,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
id: credential.id,
|
|
64
|
+
linkedVpId,
|
|
65
|
+
tenantId: credential.tenantId,
|
|
66
|
+
linkedVpFrom: publishedAt,
|
|
67
|
+
createdAt: credential.createdAt,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async lvpUnpublishCredential(args: UnpublishCredentialArgs, context: RequiredContext): Promise<boolean> {
|
|
72
|
+
const { linkedVpId } = args
|
|
73
|
+
|
|
74
|
+
// Find credential by linkedVpId and tenantId
|
|
75
|
+
const credentials = await context.agent.crsGetCredentials({
|
|
76
|
+
filter: [{ linkedVpId }],
|
|
77
|
+
})
|
|
78
|
+
if (credentials.length === 0) {
|
|
79
|
+
return Promise.reject(Error(`No credential found with linkedVpId ${linkedVpId}`))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const credential = credentials[0]
|
|
83
|
+
await context.agent.crsUpdateCredential({
|
|
84
|
+
id: credential.id,
|
|
85
|
+
linkedVpId: undefined,
|
|
86
|
+
linkedVpFrom: undefined,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async lvpHasEntry(args: HasLinkedVPEntryArgs, context: RequiredContext): Promise<boolean> {
|
|
93
|
+
const { linkedVpId } = args
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const credentials = await context.agent.crsGetCredentials({
|
|
97
|
+
filter: [{ linkedVpId }],
|
|
98
|
+
})
|
|
99
|
+
return credentials.length > 0
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async lvpGetServiceEntries(args: GetServiceEntriesArgs, context: RequiredContext): Promise<Array<LinkedVPServiceEntry>> {
|
|
106
|
+
const { tenantId } = args
|
|
107
|
+
|
|
108
|
+
// Get all published credentials (credentials with linkedVpId set)
|
|
109
|
+
const filter: any = { linkedVpId: Not(IsNull()) }
|
|
110
|
+
if (tenantId) {
|
|
111
|
+
filter.tenantId = tenantId
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const credentials = await context.agent.crsGetCredentials({
|
|
115
|
+
filter: [filter],
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
return credentials
|
|
119
|
+
.filter((cred) => cred.linkedVpId !== undefined && cred.linkedVpId !== null)
|
|
120
|
+
.flatMap((cred) => {
|
|
121
|
+
const uniformDocument = JSON.parse(cred.uniformDocument) as IVerifiableCredential
|
|
122
|
+
const holderDidForEntry = this.getHolderDid(uniformDocument)
|
|
123
|
+
return holderDidForEntry && holderDidForEntry.startsWith('did:web') ? [this.credentialToServiceEntry(cred, holderDidForEntry)] : []
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async lvpGeneratePresentation(args: GeneratePresentationArgs, context: RequiredContext): Promise<LinkedVPPresentation> {
|
|
128
|
+
const { linkedVpId } = args
|
|
129
|
+
const tenantId = this.parseTenantFromLinkedVpId(linkedVpId)
|
|
130
|
+
|
|
131
|
+
const uniqueCredentials = await context.agent.crsGetUniqueCredentials({
|
|
132
|
+
filter: [
|
|
133
|
+
{
|
|
134
|
+
linkedVpId: args.linkedVpId,
|
|
135
|
+
...(tenantId && { tenantId }),
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
})
|
|
139
|
+
if (uniqueCredentials.length === 0) {
|
|
140
|
+
return Promise.reject(Error(`No published credentials found for linkedVpId ${linkedVpId}`))
|
|
141
|
+
}
|
|
142
|
+
if (uniqueCredentials.length > 1) {
|
|
143
|
+
return Promise.reject(Error(`Multiple credentials found for linkedVpId ${linkedVpId}`))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const uniqueDigitalCredential = uniqueCredentials[0]
|
|
147
|
+
if (!uniqueDigitalCredential.uniformVerifiableCredential) {
|
|
148
|
+
return Promise.reject(Error(`uniformVerifiableCredential could not be found for credential ${uniqueDigitalCredential.digitalCredential.id}`))
|
|
149
|
+
}
|
|
150
|
+
const holderDid = this.getHolderDid(uniqueDigitalCredential.uniformVerifiableCredential)
|
|
151
|
+
if (!holderDid) {
|
|
152
|
+
return Promise.reject(Error(`Could not extract the holder did:web from cnf nor the credentialSubject id`))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Generate the Verifiable Presentation with all published credentials
|
|
156
|
+
return createLinkedVPPresentation(holderDid, uniqueDigitalCredential, context.agent)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private getHolderDid(uniformDocument: IVerifiableCredential): string | undefined {
|
|
160
|
+
// Determine holder DID for identifier resolution
|
|
161
|
+
if ('cnf' in uniformDocument && 'jwk' in uniformDocument.cnf && 'kid' in uniformDocument.cnf.jwk) {
|
|
162
|
+
return uniformDocument.cnf.jwk.kid.split('#')[0]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if ('credentialSubject' in uniformDocument) {
|
|
166
|
+
const credentialSubject = Array.isArray(uniformDocument.credentialSubject)
|
|
167
|
+
? uniformDocument.credentialSubject[0]
|
|
168
|
+
: uniformDocument.credentialSubject
|
|
169
|
+
if ('id' in credentialSubject && credentialSubject.id) {
|
|
170
|
+
if (credentialSubject.id.startsWith('did:web')) {
|
|
171
|
+
return credentialSubject.id
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return undefined
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private parseTenantFromLinkedVpId(linkedVpId: string): string | undefined {
|
|
180
|
+
const idx = linkedVpId.lastIndexOf('@')
|
|
181
|
+
return idx === -1 ? undefined : linkedVpId.substring(idx + 1)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private generateLinkedVpId(): string {
|
|
185
|
+
return `lvp-${Math.random().toString(36).substring(2, 15)}`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async ensureLinkedVpIdUnique(linkedVpId: string, context: RequiredContext, tenantId?: string): Promise<void> {
|
|
189
|
+
const credentials = await context.agent.crsGetCredentials({
|
|
190
|
+
filter: [{ linkedVpId, ...(tenantId && { tenantId }) }],
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
if (credentials.length > 0) {
|
|
194
|
+
throw new Error(`LinkedVP ID ${linkedVpId} already exists${tenantId ? ` for tenant ${tenantId}` : ''}`)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private buildLinkedVpId(linkedVpId: string | undefined, tenantId: string | undefined) {
|
|
199
|
+
let finalLinkedVpId = linkedVpId || this.generateLinkedVpId()
|
|
200
|
+
|
|
201
|
+
// Append tenantId if provided and not already present
|
|
202
|
+
if (tenantId && tenantId !== '' && !finalLinkedVpId.includes('@')) {
|
|
203
|
+
finalLinkedVpId = `${finalLinkedVpId}@${tenantId}`
|
|
204
|
+
}
|
|
205
|
+
return finalLinkedVpId
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private getBaseUrlFromDid(holderDid: string): string {
|
|
209
|
+
if (!holderDid.startsWith('did:web:')) {
|
|
210
|
+
throw new Error(`Invalid DID: ${holderDid}, must be did:web`)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const withoutPrefix = holderDid.replace('did:web:', '') // example.com:tenants:tenant1
|
|
214
|
+
const parts = withoutPrefix.split(':')
|
|
215
|
+
const domain = parts.shift()! // example.com
|
|
216
|
+
const path = parts.join('/') // tenants/tenant1
|
|
217
|
+
|
|
218
|
+
return path
|
|
219
|
+
? `https://${domain}/${path}` // https://example.com/tenants/tenant1
|
|
220
|
+
: `https://${domain}` // https://example.com
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private buildServiceEndpoint(holderDid: string, linkedVpId: string): string {
|
|
224
|
+
const baseUrl = this.getBaseUrlFromDid(holderDid)
|
|
225
|
+
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
|
|
226
|
+
return `${cleanBaseUrl}/linked-vp/${linkedVpId}`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private credentialToServiceEntry(credential: DigitalCredential, holderDid: string): LinkedVPServiceEntry {
|
|
230
|
+
if (!credential.linkedVpId) {
|
|
231
|
+
throw new Error(`Credential ${credential.id} does not have a linkedVpId`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
id: `${holderDid}#${credential.linkedVpId}`,
|
|
236
|
+
type: 'LinkedVerifiablePresentation',
|
|
237
|
+
serviceEndpoint: this.buildServiceEndpoint(holderDid, credential.linkedVpId),
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|