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
1 change: 1 addition & 0 deletions packages/learn-card-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@learncard/didkit-plugin": "workspace:^",
"@learncard/init": "workspace:^",
"@learncard/learn-cloud-plugin": "workspace:*",
"@learncard/ler-rs-plugin": "workspace:*",
"@learncard/simple-signing-plugin": "workspace:*",
"@learncard/types": "workspace:*",
"@rollup/plugin-json": "^4.1.0",
Expand Down
5 changes: 4 additions & 1 deletion packages/learn-card-cli/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import figlet from 'figlet';
import { program } from 'commander';
import clipboard from 'clipboardy';

import { getLerRsPlugin } from '@learncard/ler-rs-plugin';

import { generateRandomSeed } from './random';

Expand Down Expand Up @@ -74,10 +75,12 @@ program
),
});

globalThis.learnCard = await _learnCard.addPlugin(
const simpleSigningLc = await _learnCard.addPlugin(
await getSimpleSigningPlugin(_learnCard, 'https://api.learncard.app/trpc')
);

globalThis.learnCard = await simpleSigningLc.addPlugin(getLerRsPlugin(simpleSigningLc));

globalThis.types = types;
globalThis.getTestCache = getTestCache;

Expand Down
3 changes: 3 additions & 0 deletions packages/plugins/ler-rs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
tsconfig.tsbuildinfo
210 changes: 210 additions & 0 deletions packages/plugins/ler-rs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# @learncard/ler-rs-plugin

Create, package, and verify Learning & Employment Record Resume (LER-RS) credentials.

## Install

This plugin is part of the LearnCard monorepo and is built with the workspace.

## API

- `createLerRecord(params: CreateLerRecordParams): Promise<VC>`
- `createLerPresentation(params: CreateLerPresentationParams): Promise<VP>`
- `verifyLerPresentation(params: VerifyLerPresentationParams): Promise<VerificationResult>`

See `src/types.ts` for types and `src/ler-rs.ts` for implementation details.

## Notes

- Follows the Container + Verifiable Proof pattern by wrapping self-asserted containers and embedding VCs in `verifications` arrays.
- Ensures credential and presentation `type` fields are non-empty arrays.

---

## Requirements

- Your `LearnCard` must already have the VC plugin installed (provided by standard `@learncard/init` initializers).
- Add this plugin by passing the base LearnCard to the factory and then calling `addPlugin`:

```ts
import { getLerRsPlugin } from '@learncard/ler-rs-plugin';

// baseLc should already include the VC plugin (e.g., via @learncard/init)
const lc = await baseLc.addPlugin(getLerRsPlugin(baseLc));
```

The plugin captures `baseLc` internally to issue and verify VCs/VPs.

## Quick start

```ts
import type {
PersonProfile,
WorkHistoryItem,
EducationHistoryItem,
CertificationItem,
} from '@learncard/ler-rs-plugin';
import { getLerRsPlugin } from '@learncard/ler-rs-plugin';

// 1) Add plugin
const lc = await baseLc.addPlugin(getLerRsPlugin(baseLc));

// 2) Build a LER-RS credential (self-asserted + optional embedded VCs)
const person: PersonProfile = {
id: 'did:example:alice',
givenName: 'Alice',
familyName: 'Anderson',
email: '[email protected]',
};

const workHistory: WorkHistoryItem[] = [
{
position: 'Marketing Professional',
employer: 'ABC Company',
start: '2022-01-01',
end: '2024-06-01',
narrative: 'Led a multi-channel campaign with 200% ROI.',
},
];

const educationHistory: EducationHistoryItem[] = [
{
institution: 'State University',
degree: 'B.S. Business',
specializations: ['Marketing Analytics'],
start: '2018-09-01',
end: '2022-05-15',
},
];

const certifications: CertificationItem[] = [
{
name: 'Google Analytics Certification',
issuingAuthority: 'Google',
status: 'active',
narrative: 'Validated proficiency in GA4 and attribution modeling.',
},
];

const skills = ['SEO/SEM', 'Content Strategy', 'Team Leadership'];

const lerVc = await lc.invoke.createLerRecord({
person,
workHistory,
educationHistory,
certifications,
skills,
});

// 3) Package into a VP (must include at least one LER-RS VC)
const vp = await lc.invoke.createLerPresentation({
credentials: [lerVc],
domain: 'apply.acme.com',
challenge: 'a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8',
});

// 4) Verify the presentation
const verification = await lc.invoke.verifyLerPresentation({
presentation: vp,
domain: 'apply.acme.com',
challenge: 'a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8',
});

console.log(verification.verified);
for (const r of verification.credentialResults) {
console.log(r.credential.id, r.verified, r.isSelfIssued, r.errors);
}
```

## Wrapping existing VCs inside containers

Each container item (`workHistory`, `educationHistory`, `certifications`) can embed externally issued VCs as verifications. Provide `verifiableCredential` in the item to include it under `verifications`:

```ts
// Assume employmentVc is a third-party or previously issued VC
const employmentVc = await lc.read.get('urn:uuid:employment-123');

const lerWithProof = await lc.invoke.createLerRecord({
person,
workHistory: [
{
narrative: 'My key responsibilities and outcomes.',
verifiableCredential: employmentVc,
},
],
});
```

## Schema mapping guidance

This plugin follows a "Container + Verifiable Proof" model. For `credentialSubject.ler`, we map as follows (see `src/ler-rs.ts`):

- __person__
- From `params.person`
- Maps to `ler.person` with `name.givenName`, `name.familyName`, and `name.formattedName`

- __communication__
- If `person.email` is provided, becomes `ler.communication.emails = [{ address: email }]`

- __skills__
- `params.skills: string[]` β†’ `ler.skills = [{ name: string }]`

- __employmentHistories__ (from `params.workHistory`)
- `employer` β†’ `container.organization.tradeName`
- `position`, `start`, `end` β†’ `container.positionHistories = [{ title, start, end }]`
- `narrative` β†’ `container.narrative`
- `verifiableCredential` β†’ `container.verifications = [VC]`
- Any other keys on the item are merged into the container as-is

- __educationAndLearnings__ (from `params.educationHistory`)
- `institution`, `start`, `end` β†’ same key names on container
- `degree`, `specializations` β†’ `container.educationDegrees = [{ name: degree, specializations }]`
- `narrative` β†’ `container.narrative`
- `verifiableCredential` β†’ `container.verifications = [VC]`

- __certifications__ (from `params.certifications`)
- All provided keys are copied into the container
- `narrative` β†’ `container.narrative`
- `verifiableCredential` β†’ `container.verifications = [VC]`

Resulting VC shape (high-level):

```ts
{
'@context': ['https://www.w3.org/ns/credentials/v2'],
type: ['VerifiableCredential', 'LERRS'],
issuer: 'did:...issuer',
credentialSubject: {
id: 'did:...subject',
ler: {
person: { id, name: { givenName, familyName, formattedName } },
communication?: { emails?: [{ address }] },
skills?: [{ name }],
employmentHistories?: [{ ...container, verifications?: [VC] }],
educationAndLearnings?: [{ ...container, verifications?: [VC] }],
certifications?: [{ ...container, verifications?: [VC] }],
narratives?: string[],
}
}
}
```

## Verification behavior

- `verifyLerPresentation` verifies the VP and each embedded VC.
- `VerificationResult.verified` is true when:
- The presentation verifies, and
- Every credential either verifies OR is considered self-issued.
- A credential is considered __self-issued__ when:
- It has type `LERRS`, or
- Its `issuer` DID equals the VP `holder` DID.

## Troubleshooting

- __Non-empty type arrays__: VC/VP `type` are always emitted as non-empty arrays to satisfy validators (e.g., Zod schemas that require `[string, ...string[]]`).
- __Missing VC plugin__: Ensure your base LearnCard already includes `@learncard/vc-plugin` (standard in `@learncard/init` initializers).

## Contributing

- Types live in `src/types.ts`. Implementation is in `src/ler-rs.ts`.
- Please add tests for new behaviors and keep examples in this README up-to-date with the code.
44 changes: 44 additions & 0 deletions packages/plugins/ler-rs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@learncard/ler-rs-plugin",
"version": "0.1.0",
"description": "LER-RS plugin for LearnCard: create, package, and verify Learning & Employment Record Resumes",
"main": "./dist/index.js",
"module": "./dist/ler-rs-plugin.esm.js",
"files": [
"dist"
],
"scripts": {
"build": "node ./scripts/build.mjs && shx cp ./scripts/mixedEntypoint.js ./dist/index.js && tsc --p tsconfig.json",
"test": "jest --passWithNoTests",
"test:watch": "jest --watch",
"test:coverage": "jest --silent --ci --coverage --coverageReporters=\"text\" --coverageReporters=\"text-summary\""
},
"author": "Learning Economy Foundation (www.learningeconomy.io)",
"license": "MIT",
"homepage": "https://github.com/WeLibraryOS/LearnCard#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/WeLibraryOS/LearnCard.git"
},
"bugs": {
"url": "https://github.com/WeLibraryOS/LearnCard/issues"
},
"devDependencies": {
"@types/jest": "^29.2.2",
"@types/node": "^17.0.31",
"aqu": "0.4.3",
"esbuild": "^0.14.38",
"esbuild-jest": "^0.5.0",
"esbuild-plugin-copy": "^1.3.0",
"jest": "^29.3.0",
"rimraf": "^3.0.2",
"shx": "^0.3.4",
"ts-jest": "^29.0.3"
},
"types": "./dist/index.d.ts",
"dependencies": {
"@learncard/core": "workspace:*",
"@learncard/types": "workspace:*",
"@learncard/vc-plugin": "workspace:*"
}
}
21 changes: 21 additions & 0 deletions packages/plugins/ler-rs/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"name": "ler-rs-plugin",
"sourceRoot": "packages/plugins/ler-rs/src",
"projectType": "library",
"root": "packages/plugins/ler-rs",
"tags": [],
"implicitDependencies": ["types", "core", "vc-plugin"],
"namedInputs": {
"scripts": ["{projectRoot}/scripts/**/*"],
"source": ["{projectRoot}/src/**/*"],
"dist": ["{projectRoot}/dist/**/*"]
},
"targets": {
"build": {
"executor": "nx:run-script",
"inputs": ["source", "scripts"],
"options": { "script": "build" }
}
}
}
80 changes: 80 additions & 0 deletions packages/plugins/ler-rs/scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import path from 'path';

import esbuild from 'esbuild';
import rimraf from 'rimraf';

const buildOptions = {
// target: 'es6',
target: 'es2020',
sourcemap: true,
external: ['isomorphic-fetch', 'isomorphic-webcrypto'],
};

const configurations = [
{
keepNames: true,
bundle: true,
sourcemap: 'external',
incremental: true,
tsconfig: 'tsconfig.json',
plugins: [],
entryPoints: ['src/index.ts'],
format: 'cjs',
outfile: 'dist/ler-rs-plugin.cjs.development.js',
...buildOptions,
},
{
keepNames: true,
bundle: true,
sourcemap: 'external',
incremental: true,
tsconfig: 'tsconfig.json',
plugins: [],
entryPoints: ['src/index.ts'],
minify: true,
format: 'cjs',
outfile: 'dist/ler-rs-plugin.cjs.production.min.js',
...buildOptions,
},
{
keepNames: true,
bundle: true,
sourcemap: 'external',
incremental: true,
tsconfig: 'tsconfig.json',
plugins: [],
entryPoints: ['src/index.ts'],
format: 'esm',
outfile: 'dist/ler-rs-plugin.esm.js',
...buildOptions,
},
];

function asyncRimraf(path) {
return new Promise((resolve, reject) => {
rimraf(path, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}

await Promise.all(
configurations.map(async config => {
var dir = config.outdir || path.dirname(config.outfile);
await asyncRimraf(dir).catch(() => {
console.log('Unable to delete directory', dir);
});
})
);

await Promise.all(configurations.map(config => esbuild.build(config))).catch(err => {
console.error('❌ Build failed');
process.exit(1);
});

console.log('βœ” Build successful');
process.exit(0);
Loading
Loading