Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/stale-roses-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@learncard/types": patch
"@learncard/network-plugin": patch
"@learncard/network-brain-service": patch
---

feat: Issue & Verify API routes
62 changes: 62 additions & 0 deletions docs/sdks/learncard-core/construction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:
Expand All @@ -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" %}
Expand Down
33 changes: 33 additions & 0 deletions packages/learn-card-types/src/lcn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,39 @@ export const LCNSigningAuthorityForUserValidator = z.object({
});
export type LCNSigningAuthorityForUserType = z.infer<typeof LCNSigningAuthorityForUserValidator>;

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<typeof IssueCredentialInputValidator>;

export const VerifyCredentialInputValidator = z.object({
credential: VCValidator,
options: z
.object({
verifyExpiration: z.boolean().optional(),
})
.optional(),
});
export type VerifyCredentialInput = z.infer<typeof VerifyCredentialInputValidator>;

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<typeof VerificationResultValidator>;

export const AutoBoostConfigValidator = z.object({
boostUri: z.string(),
signingAuthority: z.object({
Expand Down
53 changes: 53 additions & 0 deletions packages/plugins/learn-card-network/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
19 changes: 19 additions & 0 deletions packages/plugins/learn-card-network/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
ContactMethodType,
InboxCredentialQuery,
IssueInboxCredentialResponseType,
VerificationResult,
// Shared Skills/Frameworks/Tags (non-flat)
TagType,
SkillFrameworkType,
Expand Down Expand Up @@ -210,6 +211,24 @@ export type LearnCardNetworkPluginMethods = {
getIncomingCredentials: (from?: string) => Promise<SentCredentialInfo[]>;
deleteCredential: (uri: string) => Promise<boolean>;

issueCredentialWithNetwork: (
credential: UnsignedVC,
options?: {
signingAuthority?: { endpoint: string; name: string };
encrypt?: boolean;
}
) => Promise<VC | JWE>;

issueCredential: (
credential: UnsignedVC,
signingOptions?: Partial<ProofOptions>
) => Promise<VC>;

verifyCredentialWithNetwork: (
credential: VC,
options?: { verifyExpiration?: boolean }
) => Promise<VerificationResult>;

sendPresentation: (profileId: string, vp: VP, encrypt?: boolean) => Promise<string>;
acceptPresentation: (uri: string) => Promise<boolean>;
getReceivedPresentations: (from?: string) => Promise<SentCredentialInfo[]>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
VCValidator,
SentCredentialInfoValidator,
JWEValidator,
IssueCredentialInputValidator,
VerifyCredentialInputValidator,
VerificationResultValidator,
} from '@learncard/types';

import { acceptCredential, sendCredential } from '@helpers/credential.helpers';
Expand All @@ -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
Expand Down Expand Up @@ -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;
Loading
Loading