Skip to content

Conversation

Custard7
Copy link
Collaborator

@Custard7 Custard7 commented Aug 15, 2025

Overview

🎟 Relevant Jira Issues

LC-1304

📚 What is the context and goal of this PR?

Migrate the LinkedClaims plugin to use Open Badges v3 EndorsementCredential (OBv3) with VC v2, eliminating our custom LinkedClaims context and partner-specific fields. Provide simple APIs to:

  • Create an EndorsementCredential for an existing VC.
  • Verify the EndorsementCredential.
  • Store it (encrypted) and index it for retrieval.
  • Retrieve endorsements for a given original VC.

🥴 TL; RL:

Replaced our custom endorsement schema/context with the OBv3 native EndorsementCredential:

  • Contexts: ["https://www.w3.org/ns/credentials/v2", "https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json"]
  • Types: ["VerifiableCredential", "EndorsementCredential"]
  • Subject: type ["EndorsementSubject"], credentialSubject.id points to the original VC or its subject.
  • Input fields now: endorsementComment, name, description.
  • Storage uses encrypted upload by default.
  • CLI help samples updated accordingly.

💡 Feature Breakdown (screenshots & videos encouraged!)

  • Plugin core (getLinkedClaimsPlugin()):
    • endorseCredential(original, { endorsementComment?, name?, description? })
      • Issues an OBv3 EndorsementCredential with VC v2 + OBv3 contexts, type includes EndorsementCredential, credentialSubject.type includes EndorsementSubject, credentialSubject.id references the endorsed target.
    • verifyEndorsement(endorsement)
      • Validates VC v2 + OBv3 contexts, presence of EndorsementCredential type, EndorsementSubject subject with id.
    • storeEndorsement(endorsement, { storeName?, indexName? })
      • Uses encrypted upload (uploadEncrypted). Index record includes endorsedId, subjectId, and originalCredentialId set to the endorsed subject id; category: "Endorsement".
    • getEndorsements(original, { indexName? })
      • Queries by originalCredentialId (if original has id) or by endorsedId (first subject id).
  • Types
    • EndorsementDetails: { endorsementComment?: string; name?: string; description?: string }
  • CLI
    • Example updated to use endorsementComment rather than legacy fields.
  • Docs
    • README updated to OBv3 usage, contexts, and new fields (no legacy schema).

🛠 Important tradeoffs made:

  • Dropped custom context and partner fields (e.g., recommendationText, portfolio, howKnow, qualifications, rating, tags, reference, metadata) to prevent schema drift and ensure standards compliance.
  • No default or typed evidence field in EndorsementDetails to keep the input surface minimal; can be revisited in a follow-up if needed by use cases.
  • Storage now uses encrypted upload; requires a store provider that supports uploadEncrypted.

🔍 Types of Changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Chore (refactor, documentation update, etc)

💳 Does This Create Any New Technical Debt? ( If yes, please describe and add JIRA TODOs )

  • No
  • Yes
    • The custom context at packages/learn-card-contexts/linked-claims/endorsement/1.0.0.json is obsolete; follow-up to deprecate/remove and update any references.
    • CLI has existing TS typing warnings (e.g., gradient-string, REPL options) we may address separately.

Testing

🔬 How Can Someone QA This?

  1. Build:

    • pnpm nx start cli
  2. In the CLI REPL:

    // Obtain an example VC and issue it
    uvc = await learnCard.invoke.getTestVc();
    vc = await learnCard.invoke.issueCredential(uvc);
    
    // Create an OBv3 endorsement
    endorsement = await learnCard.invoke.endorseCredential(vc, {
      endorsementComment: 'Great teammate',
      name: `Endorsement of ${vc.id ?? vc.credentialSubject?.id}`,
      description: 'Peer endorsement',
    });
    
    // Verify, store (encrypted), and retrieve
    await learnCard.invoke.verifyEndorsement(endorsement);
    await learnCard.invoke.storeEndorsement(endorsement);
    endorsements = await learnCard.invoke.getEndorsements(vc);
    await learnCard.invoke.verifyEndorsement(endorsements[0]);

Confirm:
- @context includes VC v2 + OBv3.
- type includes EndorsementCredential.
- credentialSubject.type includes EndorsementSubject, and credentialSubject.id points at the original VC or its subject.
- Retrieval via index works as expected.

#### 📱 🖥 Which devices would you like help testing on?
Node/CLI on macOS or any environment configured for LearnCard monorepo

#### 🧪 Code Coverage
Manual QA for issuance, verification, storage, and retrieval. Follow-up recommended for unit/integration tests (issuance with/without optional fields, verification failures, storage/retrieval across providers).

#### Documentation
📜 Gitbook
- Update plugin docs to reflect OBv3 EndorsementCredential usage, contexts, and fields (endorsementComment, name, description).
- Link to be added upon publish.

#### 📊 Storybook
N/A

<!--start_gitstream_placeholder-->
### ✨ PR Description
Purpose: Add LinkedClaims plugin to support creating, verifying, storing and retrieving endorsement credentials linked to original credentials.

Main changes:
- Implemented endorseCredential, verifyEndorsement, storeEndorsement and getEndorsements methods based on Open Badges v3 specification
- Added unit tests for endorsement creation and verification flows
- Integrated LinkedClaims plugin into LearnCard CLI for credential endorsement functionality

_Generated by LinearB AI and added by gitStream._
<sub>AI-generated content may contain inaccuracies. Please verify before using. **[We'd love your feedback!](mailto:[email protected])** 🚀</sub>
<!--end_gitstream_placeholder-->

Copy link

changeset-bot bot commented Aug 15, 2025

🦋 Changeset detected

Latest commit: c8b4213

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@learncard/cli Patch
@learncard/linked-claims-plugin Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link

netlify bot commented Aug 15, 2025

Deploy Preview for learncarddocs canceled.

Name Link
🔨 Latest commit c8b4213
🔍 Latest deploy log https://app.netlify.com/projects/learncarddocs/deploys/68e02923998fe5000863ba6b

@Custard7 Custard7 changed the title Feat: LinkedClaims Plugin - Endorsements [LC-1304] Feat: LinkedClaims Plugin - Endorsements Aug 15, 2025
Custard7 and others added 3 commits August 15, 2025 16:40
…ng sanitization

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
@Custard7 Custard7 marked this pull request as ready for review August 15, 2025 20:52
@Custard7
Copy link
Collaborator Author

@claude please review this PR and recommend merging / not

Copy link
Contributor

claude bot commented Aug 15, 2025

Claude finished @Custard7's task —— View job


PR Review Complete

I've completed my review of the LinkedClaims plugin PR. Here's my comprehensive analysis:

Strengths

  1. Standards Compliance: Excellent migration from custom context to Open Badges v3 EndorsementCredential with VC v2 - this ensures interoperability and prevents schema drift.

  2. Clean API Design: The four core methods (endorseCredential, verifyEndorsement, storeEndorsement, getEndorsements) provide a logical and complete workflow.

  3. Plugin Architecture: Follows LearnCard plugin patterns correctly with proper type definitions and dependency management.

  4. Encrypted Storage: Using uploadEncrypted by default is a good security practice for endorsement data.

  5. Flexible Provider Selection: Smart fallback logic for choosing store and index providers (pickStoreProvider, pickIndexProvider).

⚠️ Issues Found

  1. Documentation Inconsistency: The README shows an evidence field in the usage example (line 24) and type definition (line 49), but this field is not implemented in the actual EndorsementDetails type or the endorseCredential method. This will confuse users.

  2. Missing Tests: No unit tests or integration tests are provided for the new functionality. This is concerning for a new plugin, especially one handling credential verification and storage.

  3. Security Alert - False Positive: The GitHub security alert about URL sanitization at line 82 is a false positive. The code correctly validates against the exact W3C credentials v2 context URL, which is the standard validation approach.

  4. Error Handling: Limited error handling in some areas (e.g., storeEndorsement doesn't handle encryption failures gracefully).

📋 Detailed Code Review

packages/plugins/linked-claims/src/index.ts:

  • Line 82: The security scanner flagged this, but it's actually correct validation logic
  • Lines 111-112: Good fallback ID generation, though consider using a more deterministic approach
  • Lines 45-48: Good validation for ensuring target ID exists
  • Line 107: Uses uploadEncrypted correctly but lacks error handling

packages/plugins/linked-claims/src/types.ts:

  • Clean type definitions, properly extends core types
  • Missing evidence field that's documented in README

packages/plugins/linked-claims/README.md:

  • Documents non-existent evidence field
  • Otherwise well-structured documentation

🔧 Recommended Fixes Before Merge

  1. Fix Documentation: Remove evidence field from README examples and type documentation, or implement it in the code if it's intended functionality.

  2. Add Tests: At minimum, add tests for:

    • endorseCredential with various input combinations
    • verifyEndorsement with valid/invalid credentials
    • Error cases (missing IDs, invalid contexts, etc.)
  3. Improve Error Handling: Add try/catch blocks around encryption/storage operations.

💡 Breaking Changes Assessment

This is appropriately marked as a breaking change since it:

  • Removes custom context and legacy fields
  • Changes the endorsement data structure
  • Requires encrypted storage support

The breaking changes are justified for standards compliance.

🎯 Recommendation: DO NOT MERGE YET

While the core implementation is solid and the architectural approach is excellent, the documentation inconsistency and lack of tests make this not ready for production. The issues are relatively minor and should be quick to fix.

Required before merge:

  1. Fix README documentation to match actual implementation
  2. Add basic test coverage for the four main methods
  3. Consider adding error handling improvements

Security note: The flagged "security issue" is actually correct validation code and can be ignored.

This is high-quality work that follows best practices - just needs the documentation fixed and some test coverage added.

Copy link
Contributor

gitstream-cm bot commented Aug 15, 2025

🥷 Code experts: TaylorBeeston

Custard7 has most 👩‍💻 activity in the files.
TaylorBeeston, Custard7 have most 🧠 knowledge in the files.

See details

packages/learn-card-cli/package.json

Activity based on git-commit:

Custard7
OCT
SEP
AUG 1 additions & 0 deletions
JUL 2 additions & 0 deletions
JUN
MAY

Knowledge based on git-blame:
TaylorBeeston: 89%
Custard7: 8%

packages/learn-card-cli/src/index.tsx

Activity based on git-commit:

Custard7
OCT
SEP
AUG 9 additions & 0 deletions
JUL 30 additions & 2 deletions
JUN 1 additions & 1 deletions
MAY

Knowledge based on git-blame:
TaylorBeeston: 75%
Custard7: 25%

pnpm-lock.yaml

Activity based on git-commit:

Custard7
OCT 89 additions & 2 deletions
SEP 837 additions & 295 deletions
AUG 43 additions & 0 deletions
JUL 315 additions & 59 deletions
JUN 68 additions & 190 deletions
MAY

Knowledge based on git-blame:
TaylorBeeston: 72%
Custard7: 23%

✨ Comment /gs review for LinearB AI review. Learn how to automate it here.

Copy link
Contributor

@gitstream-cm gitstream-cm bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ PR Review

The PR introduces a new LinkedClaims plugin implementing Open Badges v3 EndorsementCredential functionality. The core implementation is solid, but there are several potential runtime issues related to method existence validation and weak UUID generation fallbacks.

3 issues detected:

🐞 Bug - Calling methods on objects without verifying they exist can cause runtime failures.

Details: The code assumes store providers have an uploadEncrypted method without checking if it exists. If a store provider doesn't implement this method, the code will throw a runtime error, making the plugin unusable with certain providers.
File: packages/plugins/linked-claims/src/index.ts (106-106)

🐞 Bug - Weak random ID generation can lead to ID collisions and potential data corruption.

Details: The UUID generation uses a weak fallback (Math.random().toString(36).slice(2)) when crypto.randomUUID() is unavailable. This fallback produces much shorter, less random strings that significantly increase collision risk and could lead to data integrity issues.
File: packages/plugins/linked-claims/src/index.ts (111-111)

🐞 Bug - Assumptions about method availability can lead to runtime errors with different provider implementations.

Details: Multiple method calls on index and read providers assume methods exist without validation (e.g., add, get methods). While less critical than the store issue, these could still cause runtime errors with incompatible providers.
File: packages/plugins/linked-claims/src/index.ts

Generated by LinearB AI and added by gitStream.
AI-generated content may contain inaccuracies. Please verify before using. We'd love your feedback! 🚀

storeEndorsement: async (_learnCard, endorsement, options) => {
const storeName = pickStoreProvider(learnCard, options?.storeName);
if (!storeName) throw new Error('No store plane provider available');

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐞 Bug - Missing Method Validation: Add runtime validation to check if the uploadEncrypted method exists on the store provider before calling it. Throw a more descriptive error if the method is not available.

Suggested change
const uri: string = await (learnCard.store as any)[storeName].uploadEncrypted(endorsement);
if (!learnCard.store[storeName].uploadEncrypted || typeof learnCard.store[storeName].uploadEncrypted !== 'function') {
throw new Error(`Store provider '${storeName}' does not support uploadEncrypted method`);
}


const indexName = pickIndexProvider(learnCard, options?.indexName);
let indexed = false;
let id = (endorsement as any).id || `urn:uuid:${(globalThis as any).crypto?.randomUUID?.() || Math.random().toString(36).slice(2)}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐞 Bug - Weak UUID Fallback: Use a proper UUID library or implement a more robust fallback that generates cryptographically secure random IDs with sufficient entropy. Consider throwing an error if secure random generation is unavailable.

Suggested change
let id = (endorsement as any).id || `urn:uuid:${(globalThis as any).crypto?.randomUUID?.() || Math.random().toString(36).slice(2)}`;
let id = (endorsement as any).id || `urn:uuid:${(globalThis as any).crypto?.randomUUID?.() || (() => { throw new Error('Secure random UUID generation is not available. Please use an environment that supports crypto.randomUUID().'); })()}`;

@TaylorBeeston
Copy link
Collaborator

@Custard7 I think this would be better done as a new template, and then the endorsement plugin can be just a VerifyExtension instead! Would greatly simplify things I think

Copy link
Contributor

@gitstream-cm gitstream-cm bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ PR Review

The LinkedClaims plugin implementation looks solid overall with good test coverage. However, there are previous issues that remain unresolved and one new potential bug around query construction.

1 issues detected:

🐞 Bug - Query parameter can be undefined leading to unpredictable index behavior

Details: When retrieving endorsements, if the original credential has no ID and no credentialSubject.id, the query will be constructed with endorsedId: undefined. This could cause the index query to fail or behave unexpectedly, preventing endorsement retrieval.
File: packages/plugins/linked-claims/src/index.ts (138-138)

Generated by LinearB AI and added by gitStream.
AI-generated content may contain inaccuracies. Please verify before using. We'd love your feedback! 🚀


const subjectIds = getSubjectIds(original);
const originalId = (original as any).id;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐞 Bug - Undefined Query Parameter: Add validation to ensure at least one identifier exists before constructing the query. Return an empty array or throw a descriptive error if neither originalId nor subjectIds[0] are available.

Suggested change
if (!originalId && !subjectIds[0]) {
return [];
}

Copy link
Contributor

@gitstream-cm gitstream-cm bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ PR Review

The LinkedClaims plugin implementation looks well-structured with comprehensive test coverage. However, the previous provider method validation issues remain unaddressed, and there's a missing dependency in the build configuration.

1 issues detected:

🐞 Bug - Build script imports a module that isn't declared as a dependency.

Details: The build script imports 'rimraf' but it's not listed as a dependency in package.json. This will cause the build process to fail when the script attempts to run.
File: packages/plugins/linked-claims/scripts/build.mjs (4-4)

Generated by LinearB AI and added by gitStream.
AI-generated content may contain inaccuracies. Please verify before using. We'd love your feedback! 🚀

import path from 'path';

import esbuild from 'esbuild';
import rimraf from 'rimraf';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐞 Bug - Missing Dependency: Add 'rimraf' as a devDependency in the package.json file, or replace the rimraf usage with Node.js built-in fs.rmSync() for a dependency-free solution.

Suggested change
import rimraf from 'rimraf';
import fs from 'fs';

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants