@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.
@@ -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
+ }