diff --git a/.changeset/stale-roses-exist.md b/.changeset/stale-roses-exist.md new file mode 100644 index 0000000000..1b64741a06 --- /dev/null +++ b/.changeset/stale-roses-exist.md @@ -0,0 +1,7 @@ +--- +"@learncard/types": patch +"@learncard/network-plugin": patch +"@learncard/network-brain-service": patch +--- + +feat: Issue & Verify API routes diff --git a/docs/sdks/learncard-core/construction.md b/docs/sdks/learncard-core/construction.md index 91722dfb7a..197a5fa721 100644 --- a/docs/sdks/learncard-core/construction.md +++ b/docs/sdks/learncard-core/construction.md @@ -7,6 +7,7 @@ This page provides comprehensive examples for using the LearnCard SDK. If you're | Section | Description | |---------|-------------| | [Initialize SDK Client](#initialize-sdk-client) | Basic wallet initialization | +| [API Key Initialization](#api-key-initialization) | Initialization without local seed | | [Key Generation](#key-generation) | Generating secure seeds | | [Create Credentials](#create-credentials) | Building unsigned credentials | | [Issue Credentials](#issue-credentials) | Signing credentials | @@ -61,6 +62,13 @@ const customApiWithDIDDiscovery = await initLearnCard({ vcApi: 'https://bridge.l // Constructs a LearnCard with no plugins. Useful for building your own bespoke LearnCard const customLearnCard = await initLearnCard({ custom: true }); + +// Constructs a LearnCard using an API token (no local seed required) +// See "API Key Initialization" section below for details +const apiLearnCard = await initLearnCard({ + apiKey: 'your-api-token', + network: 'https://network.learncard.com/trpc' +}); ``` The examples above are not exhaustive of possible ways to instantiate a LearnCard: @@ -87,6 +95,60 @@ import { emptyLearncard } from '@learncard/init'; const learnCard = await emptyLearnCard(); ``` +#### API Key Initialization + +For server-side applications or scenarios where you don't want to store seed material, you can initialize a LearnCard using an API token. This creates a LearnCard that authenticates via API key rather than DID-based authentication. + +{% hint style="info" %} +**When to use API Key initialization:** +- Server-side applications that need to interact with the LearnCard Network +- Scenarios where storing seed material is not desirable +- Applications that need scoped, revocable access to a profile +{% endhint %} + +**Step 1: Generate an API Token** + +First, create an API token from a seed-based LearnCard: + +```typescript +import { initLearnCard } from '@learncard/init'; + +// Initialize with seed to create the auth grant +const seedLearnCard = await initLearnCard({ seed: 'your-secure-seed', network: true }); + +// Create an auth grant with specific permissions +const grantId = await seedLearnCard.invoke.addAuthGrant({ + name: 'my-api-access', + scope: 'boosts:write credentials:write credentials:read', +}); + +// Generate an API token from the auth grant +const apiToken = await seedLearnCard.invoke.getAPITokenForAuthGrant(grantId); +``` + +**Step 2: Initialize with API Key** + +```typescript +// Use the API token to create an API key LearnCard +const apiLearnCard = await initLearnCard({ + apiKey: apiToken, + network: 'https://network.learncard.com/trpc' +}); + +// Now you can use the LearnCard for network operations +const profile = await apiLearnCard.invoke.getProfile(); +``` + +{% hint style="warning" %} +**Limitations of API Key LearnCards:** + +- **No local signing capability**: API key LearnCards cannot sign credentials locally using `issueCredential()` unless you have a [Signing Authority](../../how-to-guides/deploy-infrastructure/signing-authority.md) registered. When a signing authority is registered, `issueCredential()` will automatically delegate to network-based signing. +- **No encryption**: Cannot encrypt/decrypt data (no local keypair) +- **Scoped access**: Limited to the permissions defined in the auth grant +{% endhint %} + +For more details on auth grants and scopes, see [Auth Grants and API Tokens](../../core-concepts/architecture-and-principles/auth-grants-and-api-tokens.md). + ## Key Generation {% hint style="danger" %} diff --git a/packages/learn-card-types/src/lcn.ts b/packages/learn-card-types/src/lcn.ts index 9327158771..369b4e5aca 100644 --- a/packages/learn-card-types/src/lcn.ts +++ b/packages/learn-card-types/src/lcn.ts @@ -330,6 +330,39 @@ export const LCNSigningAuthorityForUserValidator = z.object({ }); export type LCNSigningAuthorityForUserType = z.infer; +export const IssueCredentialInputValidator = z.object({ + credential: UnsignedVCValidator, + signingAuthority: z + .object({ + endpoint: z.string(), + name: z.string(), + }) + .optional(), + options: z + .object({ + encrypt: z.boolean().optional(), + }) + .optional(), +}); +export type IssueCredentialInput = z.infer; + +export const VerifyCredentialInputValidator = z.object({ + credential: VCValidator, + options: z + .object({ + verifyExpiration: z.boolean().optional(), + }) + .optional(), +}); +export type VerifyCredentialInput = z.infer; + +export const VerificationResultValidator = z.object({ + checks: z.array(z.string()), + warnings: z.array(z.string()), + errors: z.array(z.string()), +}); +export type VerificationResult = z.infer; + export const AutoBoostConfigValidator = z.object({ boostUri: z.string(), signingAuthority: z.object({ diff --git a/packages/plugins/learn-card-network/src/plugin.ts b/packages/plugins/learn-card-network/src/plugin.ts index 8410a7afed..de77bd51ce 100644 --- a/packages/plugins/learn-card-network/src/plugin.ts +++ b/packages/plugins/learn-card-network/src/plugin.ts @@ -534,6 +534,59 @@ export async function getLearnCardNetworkPlugin( return client.credential.deleteCredential.mutate({ uri }); }, + issueCredentialWithNetwork: async (_learnCard, credential, options) => { + await ensureUser(); + + return client.credential.issueCredential.mutate({ + credential, + signingAuthority: options?.signingAuthority, + options: options?.encrypt !== undefined ? { encrypt: options.encrypt } : undefined, + }); + }, + + issueCredential: async (_learnCard, credential, signingOptions) => { + // Check if local keypair is available + let hasLocalKeypair = false; + + try { + const kp = _learnCard.id.keypair(); + hasLocalKeypair = !!kp; + } catch { + hasLocalKeypair = false; + } + + if (hasLocalKeypair) { + // Use the original VCPlugin's issueCredential + _learnCard.debug?.('LCN issueCredential: using local signing'); + return learnCard.invoke.issueCredential(credential, signingOptions); + } + + // No local keypair - delegate to network signing + _learnCard.debug?.('LCN issueCredential: delegating to network signing'); + const result = await client.credential.issueCredential.mutate({ + credential, + signingAuthority: undefined, + options: undefined, + }); + + // issueCredentialWithNetwork can return VC | JWE, but issueCredential should return VC + // If encrypted (JWE), this would be a type mismatch - for now we assume unencrypted + if ('ciphertext' in result) { + throw new Error( + 'Network signing returned encrypted credential. Use issueCredentialWithNetwork for encrypted results.' + ); + } + + return result; + }, + + verifyCredentialWithNetwork: async (_learnCard, credential, options) => { + return client.credential.verifyCredential.mutate({ + credential, + options, + }); + }, + sendPresentation: async (_learnCard, profileId, vp, encrypt = true) => { await ensureUser(); diff --git a/packages/plugins/learn-card-network/src/types.ts b/packages/plugins/learn-card-network/src/types.ts index 231a954c59..52ff5f2d54 100644 --- a/packages/plugins/learn-card-network/src/types.ts +++ b/packages/plugins/learn-card-network/src/types.ts @@ -54,6 +54,7 @@ import { ContactMethodType, InboxCredentialQuery, IssueInboxCredentialResponseType, + VerificationResult, // Shared Skills/Frameworks/Tags (non-flat) TagType, SkillFrameworkType, @@ -210,6 +211,24 @@ export type LearnCardNetworkPluginMethods = { getIncomingCredentials: (from?: string) => Promise; deleteCredential: (uri: string) => Promise; + issueCredentialWithNetwork: ( + credential: UnsignedVC, + options?: { + signingAuthority?: { endpoint: string; name: string }; + encrypt?: boolean; + } + ) => Promise; + + issueCredential: ( + credential: UnsignedVC, + signingOptions?: Partial + ) => Promise; + + verifyCredentialWithNetwork: ( + credential: VC, + options?: { verifyExpiration?: boolean } + ) => Promise; + sendPresentation: (profileId: string, vp: VP, encrypt?: boolean) => Promise; acceptPresentation: (uri: string) => Promise; getReceivedPresentations: (from?: string) => Promise; diff --git a/services/learn-card-network/brain-service/src/routes/credentials.ts b/services/learn-card-network/brain-service/src/routes/credentials.ts index 671ef1623e..8824529c2e 100644 --- a/services/learn-card-network/brain-service/src/routes/credentials.ts +++ b/services/learn-card-network/brain-service/src/routes/credentials.ts @@ -5,6 +5,9 @@ import { VCValidator, SentCredentialInfoValidator, JWEValidator, + IssueCredentialInputValidator, + VerifyCredentialInputValidator, + VerificationResultValidator, } from '@learncard/types'; import { acceptCredential, sendCredential } from '@helpers/credential.helpers'; @@ -18,11 +21,17 @@ import { import { deleteStorageForUri } from '@cache/storage'; -import { t, profileRoute } from '@routes'; +import { t, profileRoute, openRoute } from '@routes'; import { getProfileByProfileId } from '@accesslayer/profile/read'; import { getCredentialOwner } from '@accesslayer/credential/relationships/read'; import { deleteCredential } from '@accesslayer/credential/delete'; import { isRelationshipBlocked } from '@helpers/connection.helpers'; +import { issueCredentialWithSigningAuthority } from '@helpers/signingAuthority.helpers'; +import { + getSigningAuthorityForUserByName, + getPrimarySigningAuthorityForUser, +} from '@accesslayer/signing-authority/relationships/read'; +import { getEmptyLearnCard } from '@helpers/learnCard.helpers'; export const credentialsRouter = t.router({ sendCredential: profileRoute @@ -211,5 +220,92 @@ export const credentialsRouter = t.router({ return true; }), + + issueCredential: profileRoute + .meta({ + openapi: { + protect: true, + method: 'POST', + path: '/credential/issue', + tags: ['Credentials'], + summary: 'Issue a Credential', + description: + 'Issue a verifiable credential using a registered signing authority. If no signing authority is specified, the primary signing authority will be used.', + }, + requiredScope: 'credentials:write', + }) + .input(IssueCredentialInputValidator) + .output(VCValidator.or(JWEValidator)) + .mutation(async ({ ctx, input }) => { + const { profile } = ctx.user; + const { credential, signingAuthority: saInput, options } = input; + + let signingAuthorityForUser; + + if (saInput) { + signingAuthorityForUser = await getSigningAuthorityForUserByName( + profile, + saInput.endpoint, + saInput.name + ); + + if (!signingAuthorityForUser) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Signing authority '${saInput.name}' at endpoint '${saInput.endpoint}' not found for this profile.`, + }); + } + } else { + signingAuthorityForUser = await getPrimarySigningAuthorityForUser(profile); + + if (!signingAuthorityForUser) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: + 'No primary signing authority found. Register one via registerSigningAuthority or provide a specific signingAuthority in the request.', + }); + } + } + + const unsignedCredential = { ...credential }; + + unsignedCredential.issuer = signingAuthorityForUser.relationship.did; + + return issueCredentialWithSigningAuthority( + profile, + unsignedCredential, + signingAuthorityForUser, + ctx.domain, + options?.encrypt ?? false + ); + }), + + verifyCredential: openRoute + .meta({ + openapi: { + protect: false, + method: 'POST', + path: '/credential/verify', + tags: ['Credentials'], + summary: 'Verify a Credential', + description: + 'Verify a verifiable credential. This endpoint does not require authentication.', + }, + }) + .input(VerifyCredentialInputValidator) + .output(VerificationResultValidator) + .mutation(async ({ input }) => { + const { credential } = input; + + const learnCard = await getEmptyLearnCard(); + + const result = await learnCard.invoke.verifyCredential(credential); + + return { + checks: result.checks, + warnings: result.warnings, + errors: result.errors, + }; + }), }); export type CredentialsRouter = typeof credentialsRouter; diff --git a/services/learn-card-network/brain-service/test/credentials.spec.ts b/services/learn-card-network/brain-service/test/credentials.spec.ts index 555128c296..4175edbf10 100644 --- a/services/learn-card-network/brain-service/test/credentials.spec.ts +++ b/services/learn-card-network/brain-service/test/credentials.spec.ts @@ -1,8 +1,9 @@ import { vi } from 'vitest'; import { getClient, getUser } from './helpers/getClient'; import { testVc, sendBoost, sendCredential, testUnsignedBoost } from './helpers/send'; -import { Profile, Credential } from '@models'; +import { Profile, Credential, SigningAuthority } from '@models'; import * as Notifications from '@helpers/notifications.helpers'; +import * as SigningAuthorityHelpers from '@helpers/signingAuthority.helpers'; import { addNotificationToQueueSpy } from './helpers/spies'; import { getDidDocForProfile, @@ -10,6 +11,7 @@ import { setDidDocForProfile, setDidDocForProfileManager, } from '@cache/did-docs'; +import { UnsignedVC, VC } from '@learncard/types'; const noAuthClient = getClient(); let userA: Awaited>; @@ -581,4 +583,279 @@ describe('Credentials', () => { }); }); }); + + describe('issueCredential', () => { + const testUnsignedCredential: UnsignedVC = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + issuer: '', + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: 'did:example:recipient', + achievement: 'Test Achievement', + }, + }; + + const mockSignedCredential: VC = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + issuer: 'did:key:z6Mktest', + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: 'did:example:recipient', + achievement: 'Test Achievement', + }, + proof: { + type: 'Ed25519Signature2020', + created: new Date().toISOString(), + verificationMethod: 'did:key:z6Mktest#z6Mktest', + proofPurpose: 'assertionMethod', + proofValue: 'mock-proof-value', + }, + }; + + let issueCredentialSpy: ReturnType; + + beforeEach(async () => { + await Profile.delete({ detach: true, where: {} }); + await Credential.delete({ detach: true, where: {} }); + await SigningAuthority.delete({ detach: true, where: {} }); + + await userA.clients.fullAuth.profile.createProfile({ profileId: 'usera' }); + await userB.clients.fullAuth.profile.createProfile({ profileId: 'userb' }); + + issueCredentialSpy = vi + .spyOn(SigningAuthorityHelpers, 'issueCredentialWithSigningAuthority') + .mockResolvedValue(mockSignedCredential); + }); + + afterEach(() => { + issueCredentialSpy.mockRestore(); + }); + + afterAll(async () => { + await Profile.delete({ detach: true, where: {} }); + await Credential.delete({ detach: true, where: {} }); + await SigningAuthority.delete({ detach: true, where: {} }); + }); + + it('should require full auth to issue a credential', async () => { + await expect( + noAuthClient.credential.issueCredential({ credential: testUnsignedCredential }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + + await expect( + userA.clients.partialAuth.credential.issueCredential({ + credential: testUnsignedCredential, + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('should require credentials:write scope', async () => { + const userWithReadScope = await getUser('d'.repeat(64), 'credentials:read'); + await userWithReadScope.clients.fullAuth.profile.createProfile({ profileId: 'userd' }); + + await expect( + userWithReadScope.clients.fullAuth.credential.issueCredential({ + credential: testUnsignedCredential, + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('should fail if no signing authority is registered', async () => { + await expect( + userA.clients.fullAuth.credential.issueCredential({ + credential: testUnsignedCredential, + }) + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + }); + + it('should issue a credential using the primary signing authority', async () => { + const saDid = userA.learnCard.id.did(); + + await userA.clients.fullAuth.profile.registerSigningAuthority({ + endpoint: 'http://localhost:5000/test-sa', + name: 'test-sa', + did: saDid, + }); + + const result = await userA.clients.fullAuth.credential.issueCredential({ + credential: testUnsignedCredential, + }); + + expect(result).toBeDefined(); + expect(result.proof).toBeDefined(); + }); + + it('should issue a credential using a specified signing authority', async () => { + const saDid1 = userA.learnCard.id.did(); + const saDid2 = userB.learnCard.id.did(); + + await userA.clients.fullAuth.profile.registerSigningAuthority({ + endpoint: 'http://localhost:5000/sa1', + name: 'sa1', + did: saDid1, + }); + + await userA.clients.fullAuth.profile.registerSigningAuthority({ + endpoint: 'http://localhost:5000/sa2', + name: 'sa2', + did: saDid2, + }); + + const result = await userA.clients.fullAuth.credential.issueCredential({ + credential: testUnsignedCredential, + signingAuthority: { + endpoint: 'http://localhost:5000/sa2', + name: 'sa2', + }, + }); + + expect(result).toBeDefined(); + expect(result.proof).toBeDefined(); + }); + + it('should fail if specified signing authority is not found', async () => { + const saDid = userA.learnCard.id.did(); + + await userA.clients.fullAuth.profile.registerSigningAuthority({ + endpoint: 'http://localhost:5000/test-sa', + name: 'test-sa', + did: saDid, + }); + + await expect( + userA.clients.fullAuth.credential.issueCredential({ + credential: testUnsignedCredential, + signingAuthority: { + endpoint: 'http://localhost:5000/nonexistent', + name: 'nonexistent', + }, + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('should set the issuer to the signing authority DID', async () => { + const saDid = userA.learnCard.id.did(); + + await userA.clients.fullAuth.profile.registerSigningAuthority({ + endpoint: 'http://localhost:5000/test-sa', + name: 'test-sa', + did: saDid, + }); + + const credentialWithWrongIssuer: UnsignedVC = { + ...testUnsignedCredential, + issuer: 'did:example:wrong-issuer', + }; + + const result = await userA.clients.fullAuth.credential.issueCredential({ + credential: credentialWithWrongIssuer, + }); + + expect(result).toBeDefined(); + expect(result.proof).toBeDefined(); + }); + + it('should use primary signing authority when multiple are registered', async () => { + const saDid1 = userA.learnCard.id.did(); + const saDid2 = userB.learnCard.id.did(); + + await userA.clients.fullAuth.profile.registerSigningAuthority({ + endpoint: 'http://localhost:5000/sa1', + name: 'sa1', + did: saDid1, + }); + + await userA.clients.fullAuth.profile.registerSigningAuthority({ + endpoint: 'http://localhost:5000/sa2', + name: 'sa2', + did: saDid2, + }); + + await userA.clients.fullAuth.profile.setPrimarySigningAuthority({ + endpoint: 'http://localhost:5000/sa2', + name: 'sa2', + }); + + const result = await userA.clients.fullAuth.credential.issueCredential({ + credential: testUnsignedCredential, + }); + + expect(result).toBeDefined(); + expect(result.proof).toBeDefined(); + }); + }); + + describe('verifyCredential', () => { + beforeEach(async () => { + await Profile.delete({ detach: true, where: {} }); + await Credential.delete({ detach: true, where: {} }); + + await userA.clients.fullAuth.profile.createProfile({ profileId: 'usera' }); + }); + + afterAll(async () => { + await Profile.delete({ detach: true, where: {} }); + await Credential.delete({ detach: true, where: {} }); + }); + + it('should allow verification without authentication', async () => { + await expect( + noAuthClient.credential.verifyCredential({ credential: testVc }) + ).resolves.not.toThrow(); + }); + + it('should verify a credential and return result structure', async () => { + const result = await noAuthClient.credential.verifyCredential({ + credential: testVc, + }); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('checks'); + expect(result).toHaveProperty('warnings'); + expect(result).toHaveProperty('errors'); + expect(Array.isArray(result.checks)).toBe(true); + expect(Array.isArray(result.warnings)).toBe(true); + expect(Array.isArray(result.errors)).toBe(true); + }); + + it('should return errors for an invalid credential', async () => { + const invalidCredential = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + issuer: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: 'did:example:invalid-test', + }, + proof: { + type: 'Ed25519Signature2020', + created: new Date().toISOString(), + verificationMethod: + 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', + proofPurpose: 'assertionMethod', + proofValue: 'invalid-signature-value', + }, + }; + + const result = await noAuthClient.credential.verifyCredential({ + credential: invalidCredential as any, + }); + + expect(result).toBeDefined(); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should work with authenticated users as well', async () => { + const result = await userA.clients.fullAuth.credential.verifyCredential({ + credential: testVc, + }); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('checks'); + expect(result).toHaveProperty('warnings'); + expect(result).toHaveProperty('errors'); + }); + }); }); diff --git a/tests/e2e/tests/credential-issuance.spec.ts b/tests/e2e/tests/credential-issuance.spec.ts new file mode 100644 index 0000000000..e134d51406 --- /dev/null +++ b/tests/e2e/tests/credential-issuance.spec.ts @@ -0,0 +1,229 @@ +import { describe, test, expect, beforeAll } from 'vitest'; +import crypto from 'crypto'; + +import { + getLearnCard, + getLearnCardForUser, + createApiTokenForUser, + LearnCard, +} from './helpers/learncard.helpers'; + +describe('Credential Issuance and Verification via Signing Authority', () => { + let issuerLearnCard: LearnCard; + let signingAuthorityEndpoint: string; + let signingAuthorityName: string; + let signingAuthorityDid: string; + + beforeAll(async () => { + issuerLearnCard = await getLearnCardForUser('a'); + + const sa = await issuerLearnCard.invoke.createSigningAuthority('e2e-issue-sa'); + signingAuthorityEndpoint = sa.endpoint; + signingAuthorityName = 'issue-sa'; + signingAuthorityDid = sa.did; + + await issuerLearnCard.invoke.registerSigningAuthority( + signingAuthorityEndpoint, + signingAuthorityName, + signingAuthorityDid + ); + }); + + describe('issueCredential endpoint', () => { + test('should issue a credential using the primary signing authority', async () => { + const unsignedCredential = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + issuer: '', // Will be set by the endpoint + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: 'did:example:recipient', + achievement: 'Test Achievement', + }, + }; + + const signedCredential = await issuerLearnCard.invoke.issueCredentialWithNetwork( + unsignedCredential as any + ); + + expect(signedCredential).toBeDefined(); + expect(signedCredential.proof).toBeDefined(); + expect(signedCredential.issuer).toBe(signingAuthorityDid); + }); + + test('should issue a credential using a specific signing authority', async () => { + const sa2 = await issuerLearnCard.invoke.createSigningAuthority('e2e-issue-sa-2'); + await issuerLearnCard.invoke.registerSigningAuthority(sa2.endpoint, 'issue-sa-2', sa2.did); + + const unsignedCredential = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + issuer: '', + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: 'did:example:recipient2', + skill: 'TypeScript', + }, + }; + + const signedCredential = await issuerLearnCard.invoke.issueCredentialWithNetwork( + unsignedCredential as any, + { + signingAuthority: { + endpoint: sa2.endpoint, + name: 'issue-sa-2', + }, + } + ); + + expect(signedCredential).toBeDefined(); + expect(signedCredential.proof).toBeDefined(); + expect(signedCredential.issuer).toBe(sa2.did); + }); + + test('should fail if no signing authority is registered', async () => { + const newLearnCard = await getLearnCard(crypto.randomBytes(32).toString('hex')); + await newLearnCard.invoke.createServiceProfile({ + profileId: 'no-sa-test', + displayName: 'No SA Test', + bio: '', + shortBio: '', + }); + + const unsignedCredential = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + issuer: '', + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: 'did:example:recipient3', + }, + }; + + await expect( + newLearnCard.invoke.issueCredentialWithNetwork(unsignedCredential as any) + ).rejects.toThrow(); + }); + + test('should work with API key authentication', async () => { + const { token } = await createApiTokenForUser( + 'a', + ['credentials:write', 'signingAuthorities:read'] + ); + + const response = await fetch('http://localhost:4000/api/credential/issue', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + credential: { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + issuer: '', + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: 'did:example:api-key-recipient', + badge: 'API Key Test Badge', + }, + }, + }), + }); + + expect(response.status).toBe(200); + + const signedCredential = await response.json(); + + expect(signedCredential).toBeDefined(); + expect(signedCredential.proof).toBeDefined(); + }); + }); + + describe('verifyCredential endpoint', () => { + test('should verify a valid credential', async () => { + const unsignedCredential = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + issuer: '', + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: 'did:example:verify-test', + status: 'verified', + }, + }; + + const signedCredential = await issuerLearnCard.invoke.issueCredentialWithNetwork( + unsignedCredential as any + ); + + const verificationResult = await issuerLearnCard.invoke.verifyCredentialWithNetwork( + signedCredential as any + ); + + expect(verificationResult).toBeDefined(); + expect(verificationResult.checks).toContain('proof'); + expect(verificationResult.errors).toHaveLength(0); + }); + + test('should return errors for an invalid credential', async () => { + const invalidCredential = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + issuer: 'did:example:fake-issuer', + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: 'did:example:invalid-test', + }, + proof: { + type: 'Ed25519Signature2018', + created: new Date().toISOString(), + verificationMethod: 'did:example:fake-issuer#key-1', + proofPurpose: 'assertionMethod', + jws: 'invalid-signature', + }, + }; + + const verificationResult = await issuerLearnCard.invoke.verifyCredentialWithNetwork( + invalidCredential as any + ); + + expect(verificationResult).toBeDefined(); + expect(verificationResult.errors.length).toBeGreaterThan(0); + }); + + test('should be accessible without authentication via HTTP', async () => { + const unsignedCredential = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + issuer: '', + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: 'did:example:public-verify', + }, + }; + + const signedCredential = await issuerLearnCard.invoke.issueCredentialWithNetwork( + unsignedCredential as any + ); + + const response = await fetch('http://localhost:4000/api/credential/verify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + credential: signedCredential, + }), + }); + + expect(response.status).toBe(200); + + const verificationResult = await response.json(); + + expect(verificationResult).toBeDefined(); + expect(verificationResult.checks).toContain('proof'); + expect(verificationResult.errors).toHaveLength(0); + }); + }); +});