diff --git a/.changeset/rare-forks-occur.md b/.changeset/rare-forks-occur.md new file mode 100644 index 0000000000..4cb3a43155 --- /dev/null +++ b/.changeset/rare-forks-occur.md @@ -0,0 +1,6 @@ +--- +"@learncard/types": patch +"@learncard/vc-templates-plugin": patch +--- + +feat: LC-1310 - Add Evidence Support diff --git a/packages/learn-card-contexts/boosts/1.0.3.json b/packages/learn-card-contexts/boosts/1.0.3.json new file mode 100644 index 0000000000..47565a8a8b --- /dev/null +++ b/packages/learn-card-contexts/boosts/1.0.3.json @@ -0,0 +1,252 @@ +{ + "@context": { + "id": "@id", + "type": "@type", + "lcn": "https://docs.learncard.com/definitions#", + "cred": "https://www.w3.org/2018/credentials#", + "xsd": "https://www.w3.org/2001/XMLSchema#", + "EvidenceFile": { + "@id": "https://docs.learncard.com/definitions#EvidenceFile", + "@context": { + "fileName": { + "@id": "https://docs.learncard.com/definitions#evidenceFileName", + "@type": "xsd:string" + }, + "fileType": { + "@id": "https://docs.learncard.com/definitions#evidenceFileType", + "@type": "xsd:string" + }, + "fileSize": { + "@id": "https://docs.learncard.com/definitions#evidenceFileSize", + "@type": "xsd:string" + } + } + }, + "BoostCredential": { + "@context": { + "address": { + "@id": "https://purl.imsglobal.org/spec/vc/ob/vocab.html#Address" + }, + "attachments": { + "@container": "@set", + "@context": { + "title": { + "@id": "lcn:boostAttachmentTitle", + "@type": "xsd:string" + }, + "type": { + "@id": "lcn:boostAttachmentType", + "@type": "xsd:string" + }, + "url": { + "@id": "lcn:boostAttachmentUrl", + "@type": "xsd:string" + }, + "fileName": { + "@id": "lcn:boostAttachmentFileName", + "@type": "xsd:string" + }, + "fileSize": { + "@id": "lcn:boostAttachmentFileSize", + "@type": "xsd:string" + }, + "fileType": { + "@id": "lcn:boostAttachmentFileType", + "@type": "xsd:string" + } + }, + "@id": "lcn:boostAttachments" + }, + "boostId": { + "@id": "lcn:boostId", + "@type": "xsd:string" + }, + "display": { + "@context": { + "backgroundColor": { + "@id": "lcn:boostBackgroundColor", + "@type": "xsd:string" + }, + "backgroundImage": { + "@id": "lcn:boostBackgroundImage", + "@type": "xsd:string" + }, + "displayType": { + "@id": "lcn:boostDisplayType", + "@type": "xsd:string" + }, + "previewType": { + "@id": "lcn:boostPreviewType", + "@type": "xsd:string" + }, + "emoji": { + "@context": { + "activeSkinTone": { + "@id": "lcn:boostEmojiActiveSkinTone", + "@type": "xsd:string" + }, + "imageUrl": { + "@id": "lcn:boostEmojiImageUrl", + "@type": "xsd:string" + }, + "names": { + "@container": "@set", + "@id": "lcn:boostEmojiNames", + "@type": "xsd:string" + }, + "unified": { + "@id": "lcn:boostEmojiUnified", + "@type": "xsd:string" + }, + "unifiedWithoutSkinTone": { + "@id": "lcn:boostEmojiUnifiedWithoutSkinTone", + "@type": "xsd:string" + } + }, + "@id": "lcn:boostEmoji" + }, + "fadeBackgroundImage": { + "@id": "lcn:boostFadeBackgroundImage", + "@type": "xsd:boolean" + }, + "repeatBackgroundImage": { + "@id": "lcn:boostRepeatBackgroundImage", + "@type": "xsd:boolean" + } + }, + "@id": "lcn:boostDisplay" + }, + "familyTitles": { + "@context": { + "dependents": { + "@container": "@set", + "@context": { + "plural": { + "@id": "lcn:plural", + "@type": "xsd:string" + }, + "singular": { + "@id": "lcn:singular", + "@type": "xsd:string" + } + }, + "@id": "lcn:dependents" + }, + "guardians": { + "@container": "@set", + "@context": { + "plural": { + "@id": "lcn:plural", + "@type": "xsd:string" + }, + "singular": { + "@id": "lcn:singular", + "@type": "xsd:string" + } + }, + "@id": "lcn:guardians" + } + }, + "@id": "lcn:familyTitles" + }, + "groupID": { + "@id": "lcn:groupID", + "@type": "xsd:string" + }, + "skills": { + "@container": "@set", + "@context": { + "category": { + "@id": "lcn:boostSkillCategory", + "@type": "xsd:string" + }, + "skill": { + "@id": "lcn:boostSkill", + "@type": "xsd:string" + }, + "subskills": { + "@container": "@set", + "@id": "lcn:boostSubskills", + "@type": "xsd:string" + } + }, + "@id": "lcn:boostSkills" + } + }, + "@id": "lcn:boostCredential" + }, + "BoostID": { + "@id": "lcn:boostID", + "@context": { + "boostID": { + "@id": "lcn:boostIDField", + "@context": { + "fontColor": { + "@id": "lcn:boostIDFontColor", + "@type": "xsd:string" + }, + "accentColor": { + "@id": "lcn:boostIDAccentColor", + "@type": "xsd:string" + }, + "backgroundImage": { + "@id": "lcn:boostIDBackgroundImage", + "@type": "xsd:string" + }, + "dimBackgroundImage": { + "@id": "lcn:boostIDDimBackgroundImage", + "@type": "xsd:boolean" + }, + "issuerThumbnail": { + "@id": "lcn:boostIDIssuerThumbnail", + "@type": "xsd:string" + }, + "showIssuerThumbnail": { + "@id": "lcn:boostIDShowIssuerThumbnail", + "@type": "xsd:boolean" + }, + "IDIssuerName": { + "@id": "lcn:boostIDIssuerName", + "@type": "xsd:string" + }, + "idThumbnail": { + "@id": "lcn:boostIDThumbnail", + "@type": "xsd:string" + }, + "accentFontColor": { + "@id": "lcn:boostIDFontColor", + "@type": "xsd:string" + }, + "idBackgroundColor": { + "@id": "lcn:boostIDBackgroundColor", + "@type": "xsd:string" + }, + "repeatIdBackgroundImage": { + "@id": "lcn:boostIDRepeatIdBackgroundImage", + "@type": "xsd:boolean" + }, + "idDescription": { + "@id": "lcn:boostIDDescription", + "@type": "xsd:string" + } + } + } + } + }, + "CertifiedBoostCredential": { + "@id": "lcn:certifiedBoostCredential", + "@context": { + "@version": 1.1, + "boostId": { + "@id": "lcn:boostId", + "@type": "xsd:string" + }, + "boostCredential": { + "@id": "cred:VerifiableCredential", + "@type": "@id", + "@container": "@graph" + } + } + } + } +} diff --git a/packages/learn-card-types/src/obv3.ts b/packages/learn-card-types/src/obv3.ts index bc224b66f3..457bb7775b 100644 --- a/packages/learn-card-types/src/obv3.ts +++ b/packages/learn-card-types/src/obv3.ts @@ -142,10 +142,24 @@ export const ResultDescriptionValidator = z .catchall(z.any()); export type ResultDescription = z.infer; +export const EvidenceValidator = z + .object({ + id: z.string().optional(), + type: z.array(z.string()).nonempty(), + name: z.string().optional(), + narrative: z.string().optional(), + description: z.string().optional(), + genre: z.string().optional(), + audience: z.string().optional(), + }) + .catchall(z.any()); + +export type Evidence = z.infer; + export const AchievementValidator = z .object({ id: z.string().optional(), - type: z.string().array().nonempty(), + type: z.array(z.string()).nonempty(), alignment: AlignmentValidator.array().optional(), achievementType: AchievementTypeValidator.optional(), creator: ProfileValidator.optional(), @@ -162,7 +176,7 @@ export const AchievementValidator = z related: RelatedValidator.array().optional(), resultDescription: ResultDescriptionValidator.array().optional(), specialization: z.string().optional(), - tag: z.string().array().optional(), + tag: z.array(z.string()).optional(), version: z.string().optional(), }) .catchall(z.any()); @@ -219,19 +233,6 @@ export const AchievementSubjectValidator = z .catchall(z.any()); export type AchievementSubject = z.infer; -export const EvidenceValidator = z - .object({ - id: z.string().optional(), - type: z.string().or(z.string().array().nonempty()), - narrative: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), - genre: z.string().optional(), - audience: z.string().optional(), - }) - .catchall(z.any()); -export type Evidence = z.infer; - export const UnsignedAchievementCredentialValidator = UnsignedVCValidator.extend({ name: z.string().optional(), description: z.string().optional(), @@ -242,7 +243,6 @@ export const UnsignedAchievementCredentialValidator = UnsignedVCValidator.extend [typeof AchievementSubjectValidator, z.ZodArray] >, endorsement: UnsignedVCValidator.array().optional(), - evidence: EvidenceValidator.array().optional(), }); export type UnsignedAchievementCredential = z.infer; diff --git a/packages/learn-card-types/src/vc.ts b/packages/learn-card-types/src/vc.ts index 254a1c4a70..fec59955b4 100644 --- a/packages/learn-card-types/src/vc.ts +++ b/packages/learn-card-types/src/vc.ts @@ -122,7 +122,15 @@ export const TermsOfUseValidator = z export type TermsOfUse = z.infer; export const VC2EvidenceValidator = z - .object({ type: z.string().or(z.string().array().nonempty()), id: z.string().optional() }) + .object({ + id: z.string().optional(), + type: z.array(z.string()).nonempty(), + name: z.string().optional(), + narrative: z.string().optional(), + description: z.string().optional(), + genre: z.string().optional(), + audience: z.string().optional(), + }) .catchall(z.any()); export type VC2Evidence = z.infer; @@ -152,7 +160,7 @@ export const UnsignedVCValidator = z validUntil: z.string().optional(), status: CredentialStatusValidator.or(CredentialStatusValidator.array()).optional(), termsOfUse: TermsOfUseValidator.or(TermsOfUseValidator.array()).optional(), - evidence: VC2EvidenceValidator.or(VC2EvidenceValidator.array()).optional(), + evidence: z.union([VC2EvidenceValidator, z.array(VC2EvidenceValidator)]).optional(), }) .catchall(z.any()); export type UnsignedVC = z.infer; diff --git a/packages/plugins/vc-templates/src/templates.ts b/packages/plugins/vc-templates/src/templates.ts index ecf6e36e7f..45875cd3a1 100644 --- a/packages/plugins/vc-templates/src/templates.ts +++ b/packages/plugins/vc-templates/src/templates.ts @@ -119,13 +119,14 @@ export const VC_TEMPLATES: { familyTitles, skills, groupID = '', + evidence = [], } = {}, crypto ) => ({ '@context': [ 'https://www.w3.org/ns/credentials/v2', 'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json', - 'https://ctx.learncard.com/boosts/1.0.2.json', + 'https://ctx.learncard.com/boosts/1.0.3.json', ], type: ['VerifiableCredential', 'OpenBadgeCredential', 'BoostCredential'], id: `urn:uuid:${crypto.randomUUID()}`, @@ -148,6 +149,19 @@ export const VC_TEMPLATES: { }, }, }, + ...(Array.isArray(evidence) && + evidence.length > 0 && { + evidence: evidence.map(e => ({ + ...e, + type: e.type?.includes('EvidenceFile') + ? e.type + : [ + 'Evidence', + 'EvidenceFile', + ...(e.type?.filter(t => t !== 'Evidence') || []), + ], + })), + }), display, familyTitles, image: boostImage, @@ -176,13 +190,14 @@ export const VC_TEMPLATES: { familyTitles, boostID, groupID = '', + evidence = [], } = {}, crypto ) => ({ '@context': [ 'https://www.w3.org/ns/credentials/v2', 'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json', - 'https://ctx.learncard.com/boosts/1.0.1.json', + 'https://ctx.learncard.com/boosts/1.0.3.json', 'https://ctx.learncard.com/boostIDs/1.0.0.json', ], type: ['VerifiableCredential', 'OpenBadgeCredential', 'BoostCredential', 'BoostID'], @@ -215,6 +230,19 @@ export const VC_TEMPLATES: { }, } : {}), + ...(Array.isArray(evidence) && + evidence.length > 0 && { + evidence: evidence.map(e => ({ + ...e, + type: e.type?.includes('EvidenceFile') + ? e.type + : [ + 'Evidence', + 'EvidenceFile', + ...(e.type?.filter(t => t !== 'Evidence') || []), + ], + })), + }), display, familyTitles, image: boostImage, diff --git a/packages/plugins/vc-templates/src/types.ts b/packages/plugins/vc-templates/src/types.ts index 1f4b1f5c14..8c19ef29df 100644 --- a/packages/plugins/vc-templates/src/types.ts +++ b/packages/plugins/vc-templates/src/types.ts @@ -80,6 +80,22 @@ export type AddressSpec = { longitude?: number | undefined; }; }; + +export interface Evidence { + id?: string; + type: [string, ...string[]]; // Changed from string[] to ensure at least one element + name?: string; + narrative?: string; + description?: string; + genre?: string; + audience?: string; + + // Extended fields + fileName?: string; + fileType?: string; + fileSize?: string; +} + export type BoostTemplate = { did?: string; subject?: string; @@ -101,6 +117,7 @@ export type BoostTemplate = { boostID?: BoostID; address?: AddressSpec; groupID?: string; + evidence?: Evidence[]; }; /** @group VC Templates Plugin */