From 8da8ca75b72c5be9ec421785fd075625a41ec4c3 Mon Sep 17 00:00:00 2001 From: Taylor Beeston Date: Thu, 18 Dec 2025 12:08:48 -0800 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20Tag=20contracts=20in=20X-API=20?= =?UTF-8?q?Statements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../learn-cloud-service/src/constants/xapi.ts | 3 + .../src/helpers/request.helpers.ts | 9 +- .../src/helpers/xapi.helpers.ts | 19 +- .../learn-cloud-service/src/types/vp.ts | 1 + .../learn-cloud-service/src/xapi.ts | 13 +- tests/e2e/tests/dids.spec.ts | 1 + tests/e2e/tests/xapi.spec.ts | 502 +++++++++++++++++- 7 files changed, 542 insertions(+), 6 deletions(-) diff --git a/services/learn-card-network/learn-cloud-service/src/constants/xapi.ts b/services/learn-card-network/learn-cloud-service/src/constants/xapi.ts index 9fb0800508..6c4f7cb6a6 100644 --- a/services/learn-card-network/learn-cloud-service/src/constants/xapi.ts +++ b/services/learn-card-network/learn-cloud-service/src/constants/xapi.ts @@ -1,2 +1,5 @@ /** XAPI Endpoint. */ export const XAPI_ENDPOINT = process.env.XAPI_ENDPOINT; + +/** Extension IRI for contract URI in xAPI statements. */ +export const XAPI_CONTRACT_URI_EXTENSION = 'https://learncard.com/xapi/extensions/contractUri'; diff --git a/services/learn-card-network/learn-cloud-service/src/helpers/request.helpers.ts b/services/learn-card-network/learn-cloud-service/src/helpers/request.helpers.ts index 99ac68d8d1..68a19c261d 100644 --- a/services/learn-card-network/learn-cloud-service/src/helpers/request.helpers.ts +++ b/services/learn-card-network/learn-cloud-service/src/helpers/request.helpers.ts @@ -3,7 +3,12 @@ import type { XAPIRequest } from 'types/xapi'; export const createBasicAuth = (username: string, password: string) => `Basic ${btoa(`${username}:${password}`)}`; -export const createXapiFetchOptions = (request: XAPIRequest, targetUrl: URL, auth: string) => { +export const createXapiFetchOptions = ( + request: XAPIRequest, + targetUrl: URL, + auth: string, + body?: unknown +) => { const options: any = { method: request.method, headers: { ...request.headers, Authorization: auth, host: targetUrl.host }, @@ -12,7 +17,7 @@ export const createXapiFetchOptions = (request: XAPIRequest, targetUrl: URL, aut delete options.headers['content-length']; if (['POST', 'PUT', 'PATCH'].includes(request.method)) { - options.body = JSON.stringify(request.body); + options.body = JSON.stringify(body ?? request.body); } return options; diff --git a/services/learn-card-network/learn-cloud-service/src/helpers/xapi.helpers.ts b/services/learn-card-network/learn-cloud-service/src/helpers/xapi.helpers.ts index 722c1b6bd7..a2c2677e9c 100644 --- a/services/learn-card-network/learn-cloud-service/src/helpers/xapi.helpers.ts +++ b/services/learn-card-network/learn-cloud-service/src/helpers/xapi.helpers.ts @@ -1,7 +1,24 @@ import { some } from 'async'; -import { XAPI_ENDPOINT } from '../constants/xapi'; +import type { Statement } from '@xapi/xapi'; + +import { XAPI_ENDPOINT, XAPI_CONTRACT_URI_EXTENSION } from '../constants/xapi'; import { areDidsEqual } from '@helpers/did.helpers'; +/** Injects a contract URI into an xAPI statement's context.extensions. */ +export const injectContractUriIntoStatement = ( + statement: Statement, + contractUri: string +): Statement => ({ + ...statement, + context: { + ...statement.context, + extensions: { + ...statement.context?.extensions, + [XAPI_CONTRACT_URI_EXTENSION]: contractUri, + }, + }, +}); + export const verifyVoidStatement = async ( targetDid: string, did: string, diff --git a/services/learn-card-network/learn-cloud-service/src/types/vp.ts b/services/learn-card-network/learn-cloud-service/src/types/vp.ts index 17ea385459..5ba0900abb 100644 --- a/services/learn-card-network/learn-cloud-service/src/types/vp.ts +++ b/services/learn-card-network/learn-cloud-service/src/types/vp.ts @@ -4,4 +4,5 @@ export type DidAuthVP = { iss: string; vp: UnsignedVP; nonce?: string; + contractUri?: string; }; diff --git a/services/learn-card-network/learn-cloud-service/src/xapi.ts b/services/learn-card-network/learn-cloud-service/src/xapi.ts index a977fdeb57..ab41ecfef4 100644 --- a/services/learn-card-network/learn-cloud-service/src/xapi.ts +++ b/services/learn-card-network/learn-cloud-service/src/xapi.ts @@ -10,7 +10,7 @@ import { createXapiFetchOptions } from '@helpers/request.helpers'; import { XAPI_ENDPOINT } from './constants/xapi'; import type { XAPIRequest } from 'types/xapi'; import { verifyDelegateCredential } from '@helpers/credential.helpers'; -import { verifyVoidStatement } from '@helpers/xapi.helpers'; +import { injectContractUriIntoStatement, verifyVoidStatement } from '@helpers/xapi.helpers'; import { generateToken } from '@helpers/auth.helpers'; export const xapiFastifyPlugin: FastifyPluginAsync = async fastify => { @@ -40,6 +40,9 @@ export const xapiFastifyPlugin: FastifyPluginAsync = async fastify => { const decodedJwt = jwtDecode(vp); let did = decodedJwt.vp.holder; + // Contract URI can come from the VP object or the JWT payload (for backwards compatibility) + const contractUri = (decodedJwt.vp as any).contractUri ?? decodedJwt.contractUri; + if (!did) return reply.status(400).send('No valid holder DID Found in X-VP JWT'); const targetDid = @@ -117,9 +120,15 @@ export const xapiFastifyPlugin: FastifyPluginAsync = async fastify => { } } + // If there's a contractUri and this is a write request with a body, inject it + const modifiedBody = + contractUri && request.body && !isReadRequest + ? injectContractUriIntoStatement(request.body, contractUri) + : request.body; + const response = await fetch( targetUrl, - createXapiFetchOptions(request, targetUrl, auth) + createXapiFetchOptions(request, targetUrl, auth, modifiedBody) ); reply.code(response.status); diff --git a/tests/e2e/tests/dids.spec.ts b/tests/e2e/tests/dids.spec.ts index f8855789ac..9587493572 100644 --- a/tests/e2e/tests/dids.spec.ts +++ b/tests/e2e/tests/dids.spec.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest'; import { getLearnCardForUser, LearnCard } from './helpers/learncard.helpers'; +import { minimalContract } from './helpers/contract.helpers'; let a: LearnCard; let b: LearnCard; diff --git a/tests/e2e/tests/xapi.spec.ts b/tests/e2e/tests/xapi.spec.ts index 18b612f5a4..c7437723bb 100644 --- a/tests/e2e/tests/xapi.spec.ts +++ b/tests/e2e/tests/xapi.spec.ts @@ -2,7 +2,10 @@ import { describe, test, expect } from 'vitest'; import XAPI, { type Statement, type Agent } from '@xapi/xapi'; -import { getLearnCardForUser, LearnCard } from './helpers/learncard.helpers'; +import { getLearnCardForUser, LearnCard, USERS } from './helpers/learncard.helpers'; +import { normalContract, normalFullTerms } from './helpers/contract.helpers'; + +const XAPI_CONTRACT_URI_EXTENSION = 'https://learncard.com/xapi/extensions/contractUri'; const endpoint = 'http://localhost:4100/xapi'; @@ -700,4 +703,501 @@ describe('XAPI Wrapper', () => { }); }); }); + + describe('Contract-Scoped Statements', () => { + /** + * Creates a DID-Auth VP JWT with a delegate credential and optional contractUri. + * The contractUri is embedded in the VP object before signing, so it becomes + * part of the signed JWT that DIDKit can verify. + */ + const createVpWithDelegateCredential = async ( + learnCard: LearnCard, + delegateCredential: any, + contractUri?: string + ): Promise => { + const unsignedVp: any = await learnCard.invoke.newPresentation(delegateCredential); + + // Add contractUri to the VP before signing - it will be included in the signed JWT + if (contractUri) unsignedVp.contractUri = contractUri; + + console.log('unsignedVp:', JSON.stringify(unsignedVp)); + + return (await learnCard.invoke.issuePresentation(unsignedVp, { + proofPurpose: 'authentication', + proofFormat: 'jwt', + })) as unknown as string; + }; + + /** + * Helper to query xAPI statements for an actor and filter by contract URI extension. + */ + const queryStatementsByContract = async ( + actor: Agent, + auth: string, + contractUri: string + ): Promise => { + const params = new URLSearchParams({ agent: JSON.stringify(actor) }); + + const response = await fetch(`${endpoint}/statements?${params}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Experience-API-Version': '1.0.3', + 'X-VP': auth, + }, + }); + + if (response.status !== 200) return []; + + const result = await response.json(); + const statements: Statement[] = result.statements || []; + + // Filter by contract URI extension + return statements.filter( + (stmt: any) => + stmt.context?.extensions?.[XAPI_CONTRACT_URI_EXTENSION] === contractUri + ); + }; + + describe('Consenter querying statements made about them through contracts', () => { + test('User A consents to contracts X and Y, owners B and C make statements, A can query by contract', async () => { + const c = await getLearnCardForUser('c'); + + // Create contract X owned by B + const contractXUri = await b.invoke.createContract({ + contract: normalContract, + name: 'Contract X for xAPI Test', + writers: [USERS.b.profileId], + }); + + // Create contract Y owned by C + const contractYUri = await c.invoke.createContract({ + contract: normalContract, + name: 'Contract Y for xAPI Test', + writers: [USERS.c.profileId], + }); + + // User A consents to contract X + await a.invoke.consentToContract(contractXUri, { terms: normalFullTerms }); + + // User A consents to contract Y + await a.invoke.consentToContract(contractYUri, { terms: normalFullTerms }); + + // User A issues delegate credentials to B and C for writing statements + const delegateCredentialB = await a.invoke.issueCredential( + a.invoke.newCredential({ + type: 'delegate', + subject: b.id.did(), + access: ['write'], + }) + ); + + const delegateCredentialC = await a.invoke.issueCredential( + a.invoke.newCredential({ + type: 'delegate', + subject: c.id.did(), + access: ['write'], + }) + ); + + const actorA: Agent = { + account: { homePage: 'https://www.w3.org/TR/did-core/', name: a.id.did() }, + name: 'User A', + }; + + // B makes a statement about A through contract X + const statementFromB: Statement = { + actor: actorA, + verb: XAPI.Verbs.COMPLETED, + object: { + id: 'http://example.org/activity/contract-x-activity-1', + definition: { + name: { 'en-US': 'Activity from Contract X' }, + type: 'http://example.org/activity-type/contract-activity', + }, + }, + }; + + const vpB = await createVpWithDelegateCredential( + b, + delegateCredentialB, + contractXUri + ); + + const resultB = await fetch(`${endpoint}/statements`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Experience-API-Version': '1.0.3', + 'X-VP': vpB, + }, + body: JSON.stringify(statementFromB), + }); + + expect(resultB.status).toEqual(200); + + // C makes a statement about A through contract Y + const statementFromC: Statement = { + actor: actorA, + verb: XAPI.Verbs.COMPLETED, + object: { + id: 'http://example.org/activity/contract-y-activity-1', + definition: { + name: { 'en-US': 'Activity from Contract Y' }, + type: 'http://example.org/activity-type/contract-activity', + }, + }, + }; + + const vpC = await createVpWithDelegateCredential( + c, + delegateCredentialC, + contractYUri + ); + + const resultC = await fetch(`${endpoint}/statements`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Experience-API-Version': '1.0.3', + 'X-VP': vpC, + }, + body: JSON.stringify(statementFromC), + }); + + expect(resultC.status).toEqual(200); + + // User A queries and filters by contract X - should see only B's statement + const authA = (await a.invoke.getDidAuthVp({ proofFormat: 'jwt' })) as string; + const statementsForContractX = await queryStatementsByContract( + actorA, + authA, + contractXUri + ); + + expect(statementsForContractX.length).toBeGreaterThanOrEqual(1); + expect( + statementsForContractX.some( + s => + s.object && + 'id' in s.object && + s.object.id === 'http://example.org/activity/contract-x-activity-1' + ) + ).toBe(true); + expect( + statementsForContractX.some( + s => + s.object && + 'id' in s.object && + s.object.id === 'http://example.org/activity/contract-y-activity-1' + ) + ).toBe(false); + + // User A queries and filters by contract Y - should see only C's statement + const statementsForContractY = await queryStatementsByContract( + actorA, + authA, + contractYUri + ); + + expect(statementsForContractY.length).toBeGreaterThanOrEqual(1); + expect( + statementsForContractY.some( + s => + s.object && + 'id' in s.object && + s.object.id === 'http://example.org/activity/contract-y-activity-1' + ) + ).toBe(true); + expect( + statementsForContractY.some( + s => + s.object && + 'id' in s.object && + s.object.id === 'http://example.org/activity/contract-x-activity-1' + ) + ).toBe(false); + }); + }); + + describe('Contract owner querying statements they made through their contract', () => { + test('User A owns contract, B and C consent, A makes statements about both, queries are scoped to contract', async () => { + const c = await getLearnCardForUser('c'); + + // Create contract X owned by A + const contractXUri = await a.invoke.createContract({ + contract: normalContract, + name: 'Contract X Owned by A', + writers: [USERS.a.profileId], + }); + + // Create contract Y owned by A (for scoping verification) + const contractYUri = await a.invoke.createContract({ + contract: normalContract, + name: 'Contract Y Owned by A', + writers: [USERS.a.profileId], + }); + + // B and C consent to contract X + await b.invoke.consentToContract(contractXUri, { terms: normalFullTerms }); + await c.invoke.consentToContract(contractXUri, { terms: normalFullTerms }); + + // B also consents to contract Y (for scoping test) + await b.invoke.consentToContract(contractYUri, { terms: normalFullTerms }); + + // B and C issue delegate credentials to A + const delegateFromB = await b.invoke.issueCredential( + b.invoke.newCredential({ + type: 'delegate', + subject: a.id.did(), + access: ['write'], + }) + ); + + const delegateFromC = await c.invoke.issueCredential( + c.invoke.newCredential({ + type: 'delegate', + subject: a.id.did(), + access: ['write'], + }) + ); + + const actorB: Agent = { + account: { homePage: 'https://www.w3.org/TR/did-core/', name: b.id.did() }, + name: 'User B', + }; + + const actorC: Agent = { + account: { homePage: 'https://www.w3.org/TR/did-core/', name: c.id.did() }, + name: 'User C', + }; + + // A makes statement about B through contract X + const statementAboutB_X: Statement = { + actor: actorB, + verb: XAPI.Verbs.COMPLETED, + object: { + id: 'http://example.org/activity/contract-x-user-b', + definition: { + name: { 'en-US': 'User B Activity via Contract X' }, + type: 'http://example.org/activity-type/contract-activity', + }, + }, + }; + + const vpForB_X = await createVpWithDelegateCredential( + a, + delegateFromB, + contractXUri + ); + + const resultB_X = await fetch(`${endpoint}/statements`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Experience-API-Version': '1.0.3', + 'X-VP': vpForB_X, + }, + body: JSON.stringify(statementAboutB_X), + }); + + expect(resultB_X.status).toEqual(200); + + // A makes statement about C through contract X + const statementAboutC_X: Statement = { + actor: actorC, + verb: XAPI.Verbs.COMPLETED, + object: { + id: 'http://example.org/activity/contract-x-user-c', + definition: { + name: { 'en-US': 'User C Activity via Contract X' }, + type: 'http://example.org/activity-type/contract-activity', + }, + }, + }; + + const vpForC_X = await createVpWithDelegateCredential( + a, + delegateFromC, + contractXUri + ); + + const resultC_X = await fetch(`${endpoint}/statements`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Experience-API-Version': '1.0.3', + 'X-VP': vpForC_X, + }, + body: JSON.stringify(statementAboutC_X), + }); + + expect(resultC_X.status).toEqual(200); + + // A makes statement about B through contract Y (for scoping verification) + const statementAboutB_Y: Statement = { + actor: actorB, + verb: XAPI.Verbs.COMPLETED, + object: { + id: 'http://example.org/activity/contract-y-user-b', + definition: { + name: { 'en-US': 'User B Activity via Contract Y' }, + type: 'http://example.org/activity-type/contract-activity', + }, + }, + }; + + const vpForB_Y = await createVpWithDelegateCredential( + a, + delegateFromB, + contractYUri + ); + + const resultB_Y = await fetch(`${endpoint}/statements`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Experience-API-Version': '1.0.3', + 'X-VP': vpForB_Y, + }, + body: JSON.stringify(statementAboutB_Y), + }); + + expect(resultB_Y.status).toEqual(200); + + // A queries B's statements and filters by contract X - should see only contract X statement + const authB = (await b.invoke.getDidAuthVp({ proofFormat: 'jwt' })) as string; + const statementsForB_ContractX = await queryStatementsByContract( + actorB, + authB, + contractXUri + ); + + expect(statementsForB_ContractX.length).toBeGreaterThanOrEqual(1); + expect( + statementsForB_ContractX.some( + s => + s.object && + 'id' in s.object && + s.object.id === 'http://example.org/activity/contract-x-user-b' + ) + ).toBe(true); + expect( + statementsForB_ContractX.some( + s => + s.object && + 'id' in s.object && + s.object.id === 'http://example.org/activity/contract-y-user-b' + ) + ).toBe(false); + + // A queries B's statements and filters by contract Y - should see only contract Y statement + const statementsForB_ContractY = await queryStatementsByContract( + actorB, + authB, + contractYUri + ); + + expect(statementsForB_ContractY.length).toBeGreaterThanOrEqual(1); + expect( + statementsForB_ContractY.some( + s => + s.object && + 'id' in s.object && + s.object.id === 'http://example.org/activity/contract-y-user-b' + ) + ).toBe(true); + expect( + statementsForB_ContractY.some( + s => + s.object && + 'id' in s.object && + s.object.id === 'http://example.org/activity/contract-x-user-b' + ) + ).toBe(false); + + // Query C's statements filtered by contract X - should see C's statement + const authC = (await c.invoke.getDidAuthVp({ proofFormat: 'jwt' })) as string; + const statementsForC_ContractX = await queryStatementsByContract( + actorC, + authC, + contractXUri + ); + + expect(statementsForC_ContractX.length).toBeGreaterThanOrEqual(1); + expect( + statementsForC_ContractX.some( + s => + s.object && + 'id' in s.object && + s.object.id === 'http://example.org/activity/contract-x-user-c' + ) + ).toBe(true); + }); + }); + + describe('Statement without contractUri has no extension', () => { + test('Statements made without contractUri in JWT should not have the extension', async () => { + const actor: Agent = { + account: { homePage: 'https://www.w3.org/TR/did-core/', name: a.id.did() }, + name: 'User A', + }; + + const statement: Statement = { + actor, + verb: XAPI.Verbs.COMPLETED, + object: { + id: 'http://example.org/activity/no-contract-activity', + definition: { + name: { 'en-US': 'Activity Without Contract' }, + type: 'http://example.org/activity-type/generic-activity', + }, + }, + }; + + // Use regular VP without contractUri + const vp = (await a.invoke.getDidAuthVp({ proofFormat: 'jwt' })) as string; + + const result = await fetch(`${endpoint}/statements`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Experience-API-Version': '1.0.3', + 'X-VP': vp, + }, + body: JSON.stringify(statement), + }); + + expect(result.status).toEqual(200); + + // Query statements and verify the new one doesn't have the contract extension + const params = new URLSearchParams({ agent: JSON.stringify(actor) }); + + const queryResult = await fetch(`${endpoint}/statements?${params}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Experience-API-Version': '1.0.3', + 'X-VP': vp, + }, + }); + + expect(queryResult.status).toEqual(200); + + const data = await queryResult.json(); + const statements: Statement[] = data.statements || []; + + const ourStatement = statements.find( + (s: any) => s.object?.id === 'http://example.org/activity/no-contract-activity' + ); + + expect(ourStatement).toBeDefined(); + + // Should NOT have the contract URI extension + expect( + (ourStatement as any)?.context?.extensions?.[XAPI_CONTRACT_URI_EXTENSION] + ).toBeUndefined(); + }); + }); + }); }); From 7d6b07c999aa334da95d1a32edbfc51b726603a6 Mon Sep 17 00:00:00 2001 From: Taylor Beeston Date: Thu, 18 Dec 2025 13:53:54 -0800 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=94=96=20Changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/pretty-masks-poke.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pretty-masks-poke.md diff --git a/.changeset/pretty-masks-poke.md b/.changeset/pretty-masks-poke.md new file mode 100644 index 0000000000..3adeb7d0af --- /dev/null +++ b/.changeset/pretty-masks-poke.md @@ -0,0 +1,5 @@ +--- +'@learncard/learn-cloud-service': patch +--- + +Tag X-API Statements with Contract URI From f44db63a4dd9c65e4abdab98473ae2dc8f651451 Mon Sep 17 00:00:00 2001 From: Taylor Beeston Date: Fri, 19 Dec 2025 09:24:19 -0800 Subject: [PATCH 3/5] =?UTF-8?q?=E2=9C=A8=20Add=20contract=20URI=20to=20jwt?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/consentFlow/ExternalConsentFlowDoor.tsx | 8 +++++++- .../src/pages/consentFlow/FullScreenConsentFlow.tsx | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/learn-card-app/src/pages/consentFlow/ExternalConsentFlowDoor.tsx b/apps/learn-card-app/src/pages/consentFlow/ExternalConsentFlowDoor.tsx index eece910a17..61c21ca45b 100644 --- a/apps/learn-card-app/src/pages/consentFlow/ExternalConsentFlowDoor.tsx +++ b/apps/learn-card-app/src/pages/consentFlow/ExternalConsentFlowDoor.tsx @@ -225,10 +225,16 @@ const ExternalConsentFlowDoor: React.FC<{ login: boolean }> = ({ login = false } unsignedDelegateCredential ); - const unsignedDidAuthVp = + const unsignedDidAuthVp: any = await wallet.invoke.newPresentation( delegateCredential ); + + // Add contractUri to VP before signing for xAPI tracking + if (uri && typeof uri === 'string') { + unsignedDidAuthVp.contractUri = uri; + } + const vp = (await wallet.invoke.issuePresentation( unsignedDidAuthVp, { diff --git a/apps/learn-card-app/src/pages/consentFlow/FullScreenConsentFlow.tsx b/apps/learn-card-app/src/pages/consentFlow/FullScreenConsentFlow.tsx index bda2e72821..1bcc91bb0d 100644 --- a/apps/learn-card-app/src/pages/consentFlow/FullScreenConsentFlow.tsx +++ b/apps/learn-card-app/src/pages/consentFlow/FullScreenConsentFlow.tsx @@ -165,9 +165,15 @@ const FullScreenConsentFlow: React.FC = ({ unsignedDelegateCredential ); - const unsignedDidAuthVp = await wallet.invoke.newPresentation( + const unsignedDidAuthVp: any = await wallet.invoke.newPresentation( delegateCredential ); + + // Add contractUri to VP before signing for xAPI tracking + if (contractDetails?.uri) { + unsignedDidAuthVp.contractUri = contractDetails.uri; + } + const vp = (await wallet.invoke.issuePresentation(unsignedDidAuthVp, { proofPurpose: 'authentication', proofFormat: 'jwt', From 15a8d9c892b07f3a1b8110a0e8ae6a8e34207722 Mon Sep 17 00:00:00 2001 From: Taylor Beeston Date: Fri, 19 Dec 2025 09:25:10 -0800 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=94=96=20Changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/wild-regions-begin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wild-regions-begin.md diff --git a/.changeset/wild-regions-begin.md b/.changeset/wild-regions-begin.md new file mode 100644 index 0000000000..af65b34b65 --- /dev/null +++ b/.changeset/wild-regions-begin.md @@ -0,0 +1,5 @@ +--- +'learn-card-app': patch +--- + +Add Contract URI to JWTs so that X-API statements are tagged by contract From 0d0e2d222092e3c1b8d06f5ea0cb6e3968d02e35 Mon Sep 17 00:00:00 2001 From: Taylor Beeston Date: Fri, 19 Dec 2025 10:13:12 -0800 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=93=9D=20Docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../writing-consented-data.md | 4 +- .../learncloud-storage-api/xapi-reference.md | 385 +++++++++++------- docs/tutorials/sending-xapi-statements.md | 145 +++---- 3 files changed, 321 insertions(+), 213 deletions(-) diff --git a/docs/core-concepts/consent-and-permissions/writing-consented-data.md b/docs/core-concepts/consent-and-permissions/writing-consented-data.md index 5de2d77760..2b185d2ecc 100644 --- a/docs/core-concepts/consent-and-permissions/writing-consented-data.md +++ b/docs/core-concepts/consent-and-permissions/writing-consented-data.md @@ -6,8 +6,8 @@ Understand how, after consent is given, new credentials or data can be provided Contract owners can write credentials to profiles that have consented to their contracts using: -* `writeCredentialToContract`: Direct credential writing -* `writeCredentialToContractViaSigningAuthority`: Using a signing authority +- `writeCredentialToContract`: Direct credential writing +- `writeCredentialToContractViaSigningAuthority`: Using a signing authority ```mermaid sequenceDiagram diff --git a/docs/sdks/learncloud-storage-api/xapi-reference.md b/docs/sdks/learncloud-storage-api/xapi-reference.md index 558cedc06d..e2d67b6d23 100644 --- a/docs/sdks/learncloud-storage-api/xapi-reference.md +++ b/docs/sdks/learncloud-storage-api/xapi-reference.md @@ -3,6 +3,7 @@ ## Understanding Key Concepts {% hint style="success" %} + #### What is xAPI? xAPI (Experience API) is a specification that allows you to track learning experiences. It uses a simple structure of "Actor - Verb - Object" to describe activities, similar to how you might say "John completed the course" in plain English. @@ -11,6 +12,7 @@ xAPI (Experience API) is a specification that allows you to track learning exper {% endhint %} {% hint style="info" %} + #### What is a DID? A DID (Decentralized Identifier) is a unique identifier for your user that works across different systems. Think of it like an email address that works everywhere but is more secure and private. @@ -25,7 +27,7 @@ Here's how to send an xAPI statement to LearnCloud: ```typescript interface XAPIStatement { actor: { - objectType: "Agent"; + objectType: 'Agent'; name: string; account: { homePage: string; @@ -35,34 +37,34 @@ interface XAPIStatement { verb: { id: string; display: { - "en-US": string; + 'en-US': string; }; }; object: { id: string; definition: { - name: { "en-US": string }; - description: { "en-US": string }; + name: { 'en-US': string }; + description: { 'en-US': string }; type: string; }; }; } async function sendXAPIStatement( - statement: XAPIStatement, - jwt: string, - endpoint: string = "https://cloud.learncard.com/xapi" + statement: XAPIStatement, + jwt: string, + endpoint: string = 'https://cloud.learncard.com/xapi' ) { const response = await fetch(`${endpoint}/statements`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Experience-API-Version': '1.0.3', - 'X-VP': jwt + 'X-VP': jwt, }, - body: JSON.stringify(statement) + body: JSON.stringify(statement), }); - + return response; } ``` @@ -77,27 +79,27 @@ Here are examples of tracking different activities in a skills-building game: // When a player starts a new challenge const attemptStatement = { actor: { - objectType: "Agent", - name: userDid, // Use the user's DID here + objectType: 'Agent', + name: userDid, // Use the user's DID here account: { - homePage: "https://www.w3.org/TR/did-core/", - name: userDid - } + homePage: 'https://www.w3.org/TR/did-core/', + name: userDid, + }, }, verb: { - id: "http://adlnet.gov/expapi/verbs/attempted", + id: 'http://adlnet.gov/expapi/verbs/attempted', display: { - "en-US": "attempted" - } + 'en-US': 'attempted', + }, }, object: { - id: "http://yourgame.com/activities/level-1-challenge", + id: 'http://yourgame.com/activities/level-1-challenge', definition: { - name: { "en-US": "Level 1 Challenge" }, - description: { "en-US": "First challenge of the game" }, - type: "http://adlnet.gov/expapi/activities/simulation" - } - } + name: { 'en-US': 'Level 1 Challenge' }, + description: { 'en-US': 'First challenge of the game' }, + type: 'http://adlnet.gov/expapi/activities/simulation', + }, + }, }; ``` @@ -107,27 +109,27 @@ const attemptStatement = { // When a player demonstrates a skill const skillStatement = { actor: { - objectType: "Agent", + objectType: 'Agent', name: userDid, account: { - homePage: "https://www.w3.org/TR/did-core/", - name: userDid - } + homePage: 'https://www.w3.org/TR/did-core/', + name: userDid, + }, }, verb: { - id: "http://adlnet.gov/expapi/verbs/demonstrated", + id: 'http://adlnet.gov/expapi/verbs/demonstrated', display: { - "en-US": "demonstrated" - } + 'en-US': 'demonstrated', + }, }, object: { - id: "http://yourgame.com/skills/problem-solving", + id: 'http://yourgame.com/skills/problem-solving', definition: { - name: { "en-US": "Problem Solving" }, - description: { "en-US": "Successfully solved a complex game challenge" }, - type: "http://adlnet.gov/expapi/activities/skill" - } - } + name: { 'en-US': 'Problem Solving' }, + description: { 'en-US': 'Successfully solved a complex game challenge' }, + type: 'http://adlnet.gov/expapi/activities/skill', + }, + }, }; ``` @@ -137,35 +139,35 @@ const skillStatement = { // When a player completes a milestone with specific metrics const achievementStatement = { actor: { - objectType: "Agent", + objectType: 'Agent', name: userDid, account: { - homePage: "https://www.w3.org/TR/did-core/", - name: userDid - } + homePage: 'https://www.w3.org/TR/did-core/', + name: userDid, + }, }, verb: { - id: "http://adlnet.gov/expapi/verbs/mastered", + id: 'http://adlnet.gov/expapi/verbs/mastered', display: { - "en-US": "mastered" - } + 'en-US': 'mastered', + }, }, object: { - id: "http://yourgame.com/achievements/speed-runner", + id: 'http://yourgame.com/achievements/speed-runner', definition: { - name: { "en-US": "Speed Runner" }, - description: { "en-US": "Completed level with exceptional speed" }, - type: "http://adlnet.gov/expapi/activities/performance" - } + name: { 'en-US': 'Speed Runner' }, + description: { 'en-US': 'Completed level with exceptional speed' }, + type: 'http://adlnet.gov/expapi/activities/performance', + }, }, result: { success: true, completion: true, extensions: { - "http://yourgame.com/xapi/extensions/completion-time": "120_seconds", - "http://yourgame.com/xapi/extensions/score": "95" - } - } + 'http://yourgame.com/xapi/extensions/completion-time': '120_seconds', + 'http://yourgame.com/xapi/extensions/score': '95', + }, + }, }; ``` @@ -173,12 +175,12 @@ const achievementStatement = { 1. **DID Usage**: Always use the same DID in both `actor.name` and `actor.account.name`. This DID should come from your authentication process. 2. **Verb Selection**: Use standard xAPI verbs when possible. Common ones include: - * attempted - * completed - * mastered - * demonstrated - * failed - * progressed + - attempted + - completed + - mastered + - demonstrated + - failed + - progressed 3. **Activity IDs**: Use consistent, unique URLs for your activity IDs. They don't need to be real URLs, but they should be unique identifiers following URL format. 4. **Authentication**: The JWT token should be sent in the `X-VP` header. This is specific to LearnCloud's implementation. 5. **Error Handling**: Always implement proper error handling: @@ -211,16 +213,16 @@ After sending xAPI statements, you can retrieve them using the same endpoint: ```typescript // Basic GET request for statements const actor = { - account: { - homePage: "https://www.w3.org/TR/did-core/", - name: userDid // Your user's DID + account: { + homePage: 'https://www.w3.org/TR/did-core/', + name: userDid, // Your user's DID }, - name: userDid + name: userDid, }; // Convert actor to URL parameter -const params = new URLSearchParams({ - agent: JSON.stringify(actor) +const params = new URLSearchParams({ + agent: JSON.stringify(actor), }); // Fetch statements @@ -229,8 +231,8 @@ const response = await fetch(`${endpoint}/statements?${params}`, { headers: { 'Content-Type': 'application/json', 'X-Experience-API-Version': '1.0.3', - 'X-VP': jwt // Your authentication JWT - } + 'X-VP': jwt, // Your authentication JWT + }, }); ``` @@ -239,9 +241,9 @@ const response = await fetch(`${endpoint}/statements?${params}`, { 1. Users can only read statements about themselves 2. The DID in the JWT (X-VP header) must match the actor's DID 3. A 401 error means either: - * Invalid authentication - * Trying to read another user's statements - * Expired or malformed JWT + - Invalid authentication + - Trying to read another user's statements + - Expired or malformed JWT ### Delegated Access @@ -253,7 +255,7 @@ If you need to allow another party to read statements: const delegateCredential = await userA.invoke.issueCredential({ type: 'delegate', subject: userB.id.did(), - access: ['read'] // Can be ['read'], ['write'], or ['read', 'write'] + access: ['read'], // Can be ['read'], ['write'], or ['read', 'write'] }); ``` @@ -263,12 +265,109 @@ const delegateCredential = await userA.invoke.issueCredential({ const unsignedPresentation = await userB.invoke.newPresentation(delegateCredential); const delegateJwt = await userB.invoke.issuePresentation(unsignedPresentation, { proofPurpose: 'authentication', - proofFormat: 'jwt' + proofFormat: 'jwt', }); ``` 3. Use this JWT in the X-VP header to read statements +### Contract-Scoped xAPI Statements + +When using delegate credentials through ConsentFlow, you can track which contract was used to make xAPI statements. This allows you to query all statements made through a specific contract. + +#### How It Works + +When a user consents to a contract, the LearnCard app generates a delegate credential and wraps it in a Verifiable Presentation (VP). To enable contract tracking, the `contractUri` is embedded in the VP before signing: + +```typescript +// Create delegate credential for the contract owner +const unsignedDelegateCredential = wallet.invoke.newCredential({ + type: 'delegate', + subject: contractOwnerDid, + access: ['read', 'write'], +}); + +const delegateCredential = await wallet.invoke.issueCredential(unsignedDelegateCredential); + +// Create VP and embed contractUri before signing +const unsignedVp: any = await wallet.invoke.newPresentation(delegateCredential); +unsignedVp.contractUri = contractUri; // Add contract URI to VP + +const vpJwt = await wallet.invoke.issuePresentation(unsignedVp, { + proofPurpose: 'authentication', + proofFormat: 'jwt', +}); +``` + +#### Contract URI Injection + +When a statement is sent with a VP containing a `contractUri`, the LearnCloud xAPI service automatically injects it into the statement's `context.extensions`: + +```javascript +// The statement you send: +{ + actor: { ... }, + verb: { id: "http://adlnet.gov/expapi/verbs/completed", ... }, + object: { id: "http://yourgame.com/activities/level-1", ... } +} + +// Gets stored with the contract URI extension: +{ + actor: { ... }, + verb: { ... }, + object: { ... }, + context: { + extensions: { + "https://learncard.com/xapi/extensions/contractUri": "urn:lc:contract:123..." + } + } +} +``` + +#### Querying Statements by Contract + +To retrieve all statements made through a specific contract, query statements for the user and filter by the contract extension: + +```typescript +const XAPI_CONTRACT_URI_EXTENSION = 'https://learncard.com/xapi/extensions/contractUri'; + +async function getStatementsByContract( + actor: object, + jwt: string, + contractUri: string, + endpoint: string = 'https://cloud.learncard.com/xapi' +): Promise { + const params = new URLSearchParams({ agent: JSON.stringify(actor) }); + + const response = await fetch(`${endpoint}/statements?${params}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Experience-API-Version': '1.0.3', + 'X-VP': jwt, + }, + }); + + if (!response.ok) return []; + + const data = await response.json(); + + // Filter statements by contract URI + return (data.statements || []).filter( + (stmt: any) => stmt.context?.extensions?.[XAPI_CONTRACT_URI_EXTENSION] === contractUri + ); +} +``` + +#### Use Cases + +Contract-scoped xAPI statements enable: + +1. **Game/App Analytics**: Track all learning activities that occurred through a specific game or application's contract +2. **Program Reporting**: Generate reports showing all xAPI statements for learners enrolled via a specific program contract +3. **Audit Trails**: Maintain clear records of which third-party contract was responsible for each statement +4. **Multi-Contract Support**: When a user has consented to multiple contracts, distinguish statements by source + ### Voiding Statements To remove a statement (mark it as void): @@ -281,10 +380,10 @@ const statementId = (await postResponse.json())[0]; const voidStatement = { actor, verb: XAPI.Verbs.VOIDED, - object: { - objectType: 'StatementRef', - id: statementId - } + object: { + objectType: 'StatementRef', + id: statementId, + }, }; // Send void request @@ -293,9 +392,9 @@ const voidResponse = await fetch(`${endpoint}/statements`, { headers: { 'Content-Type': 'application/json', 'X-Experience-API-Version': '1.0.3', - 'X-VP': jwt + 'X-VP': jwt, }, - body: JSON.stringify(voidStatement) + body: JSON.stringify(voidStatement), }); ``` @@ -304,20 +403,20 @@ Important: You can only void statements that you created. ### Validation Tips 1. Check Response Status: - * 200: Success - * 401: Authentication/permission error - * Other: Server/request error + - 200: Success + - 401: Authentication/permission error + - Other: Server/request error 2. Common Implementation Issues: - * JWT not matching actor DID - * Missing or malformed agent parameter - * Incorrect content type header - * Missing xAPI version header + - JWT not matching actor DID + - Missing or malformed agent parameter + - Incorrect content type header + - Missing xAPI version header 3. Testing Checklist: - * Can read own statements - * Cannot read others' statements - * Delegate access works as expected - * Can void own statements - * Cannot void others' statements + - Can read own statements + - Cannot read others' statements + - Delegate access works as expected + - Can void own statements + - Cannot void others' statements Remember: The xAPI server maintains strict permissions - users can only read and modify their own statements unless explicitly delegated access by the statement owner. @@ -334,20 +433,20 @@ The xAPI API supports several query parameters to limit and filter your results: ```typescript // Basic query with filtering const queryParams = new URLSearchParams({ - agent: JSON.stringify(actor), - limit: "10", // Limit to 10 results - since: "2024-03-01T00:00:00Z", // Only statements after this date - until: "2024-03-31T23:59:59Z", // Only statements before this date - verb: "http://adlnet.gov/expapi/verbs/completed" // Only specific verb + agent: JSON.stringify(actor), + limit: '10', // Limit to 10 results + since: '2024-03-01T00:00:00Z', // Only statements after this date + until: '2024-03-31T23:59:59Z', // Only statements before this date + verb: 'http://adlnet.gov/expapi/verbs/completed', // Only specific verb }); const response = await fetch(`${endpoint}/statements?${queryParams}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Experience-API-Version': '1.0.3', - 'X-VP': jwt - } + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Experience-API-Version': '1.0.3', + 'X-VP': jwt, + }, }); ``` @@ -368,26 +467,26 @@ For very large datasets, implement pagination: ```typescript // First page -let more = ""; -const getPage = async (more) => { - const url = more || `${endpoint}/statements?${queryParams.toString()}`; - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Experience-API-Version': '1.0.3', - 'X-VP': jwt - } - }); - - const data = await response.json(); - - // Process the statements - processStatements(data.statements); - - // Check if there are more pages - return data.more || null; +let more = ''; +const getPage = async more => { + const url = more || `${endpoint}/statements?${queryParams.toString()}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Experience-API-Version': '1.0.3', + 'X-VP': jwt, + }, + }); + + const data = await response.json(); + + // Process the statements + processStatements(data.statements); + + // Check if there are more pages + return data.more || null; }; // Initial request @@ -395,7 +494,7 @@ more = await getPage(); // Get next page if available if (more) { - more = await getPage(more); + more = await getPage(more); } ``` @@ -434,12 +533,12 @@ To retrieve all statements about a specific activity regardless of verb: ```typescript const activityParams = new URLSearchParams({ - agent: JSON.stringify(actor), - activity: "http://yourdomain.com/activities/skill-assessment" + agent: JSON.stringify(actor), + activity: 'http://yourdomain.com/activities/skill-assessment', }); const response = await fetch(`${endpoint}/statements?${activityParams}`, { - // headers as before + // headers as before }); ``` @@ -449,13 +548,13 @@ To analyze progress over time, sort statements in chronological order: ```typescript const timelineParams = new URLSearchParams({ - agent: JSON.stringify(actor), - ascending: "true", - since: "2024-01-01T00:00:00Z" + agent: JSON.stringify(actor), + ascending: 'true', + since: '2024-01-01T00:00:00Z', }); const response = await fetch(`${endpoint}/statements?${timelineParams}`, { - // headers as before + // headers as before }); ``` @@ -465,12 +564,12 @@ To filter statements related to a specific skill or competency: ```typescript const skillParams = new URLSearchParams({ - agent: JSON.stringify(actor), - activity: "http://yourdomain.com/skills/problem-solving" + agent: JSON.stringify(actor), + activity: 'http://yourdomain.com/skills/problem-solving', }); const response = await fetch(`${endpoint}/statements?${skillParams}`, { - // headers as before + // headers as before }); ``` @@ -480,13 +579,13 @@ To find all completed activities: ```typescript const completedParams = new URLSearchParams({ - agent: JSON.stringify(actor), - verb: "http://adlnet.gov/expapi/verbs/completed", - since: "2024-01-01T00:00:00Z" + agent: JSON.stringify(actor), + verb: 'http://adlnet.gov/expapi/verbs/completed', + since: '2024-01-01T00:00:00Z', }); const response = await fetch(`${endpoint}/statements?${completedParams}`, { - // headers as before + // headers as before }); ``` @@ -497,14 +596,14 @@ To retrieve summary data rather than individual statements: ```typescript // First, retrieve statements with aggregation parameter const aggregateParams = new URLSearchParams({ - agent: JSON.stringify(actor), - verb: "http://adlnet.gov/expapi/verbs/experienced", - since: "2024-01-01T00:00:00Z", - format: "ids" // Retrieve only IDs for faster processing + agent: JSON.stringify(actor), + verb: 'http://adlnet.gov/expapi/verbs/experienced', + since: '2024-01-01T00:00:00Z', + format: 'ids', // Retrieve only IDs for faster processing }); const response = await fetch(`${endpoint}/statements?${aggregateParams}`, { - // headers as before + // headers as before }); // Then process locally to generate summaries @@ -512,8 +611,8 @@ const statements = await response.json(); const activityCounts = {}; statements.forEach(statement => { - const activityId = statement.object.id; - activityCounts[activityId] = (activityCounts[activityId] || 0) + 1; + const activityId = statement.object.id; + activityCounts[activityId] = (activityCounts[activityId] || 0) + 1; }); // Now activityCounts shows frequency of each activity diff --git a/docs/tutorials/sending-xapi-statements.md b/docs/tutorials/sending-xapi-statements.md index 74ef38f371..ab3aa116a0 100644 --- a/docs/tutorials/sending-xapi-statements.md +++ b/docs/tutorials/sending-xapi-statements.md @@ -4,24 +4,24 @@ This tutorial will walk you through the essential steps to send an xAPI statemen ## **What you'll accomplish:** -* Construct a basic xAPI statement. -* Send the statement to the LearnCloud xAPI endpoint. -* Retrieve and verify the statement you sent. +- Construct a basic xAPI statement. +- Send the statement to the LearnCloud xAPI endpoint. +- Retrieve and verify the statement you sent. {% embed url="https://codepen.io/Jacks-n-Smith/pen/xbbBmBV" fullWidth="false" %} ## **Prerequisites:** 1. **Understanding Key Concepts:** - * **What is xAPI?** xAPI (Experience API) is a way to track learning experiences using a simple "Actor - Verb - Object" structure (e.g., "Sarah completed 'Safety Course'"). For a deeper dive, see our [Understanding xAPI Data in LearnCard](../core-concepts/credentials-and-data/xapi-data.md) core concept page. - * **What is a DID?** A DID (Decentralized Identifier) is a unique identifier for your user. Think of it as a secure, private digital ID. More details can be found on our [Understanding DIDs](../core-concepts/identities-and-keys/decentralized-identifiers-dids.md) core concept page. + - **What is xAPI?** xAPI (Experience API) is a way to track learning experiences using a simple "Actor - Verb - Object" structure (e.g., "Sarah completed 'Safety Course'"). For a deeper dive, see our [Understanding xAPI Data in LearnCard](../core-concepts/credentials-and-data/xapi-data.md) core concept page. + - **What is a DID?** A DID (Decentralized Identifier) is a unique identifier for your user. Think of it as a secure, private digital ID. More details can be found on our [Understanding DIDs](../core-concepts/identities-and-keys/decentralized-identifiers-dids.md) core concept page. 2. **Your Environment:** - * You have the [LearnCard SDK ](../sdks/learncard-core/)initialized in your project. - * You have obtained a **JSON Web Token (JWT)** for authentication. This JWT represents the authenticated user (the "actor"). As an example of how to create this JWT, check out the[ "Create a Connected Website Tutorial."](create-a-connected-website.md) - * You have the **DID** of the authenticated user. - * The default LearnCloud xAPI endpoint is `https://cloud.learncard.com/xapi/statements`. + - You have the [LearnCard SDK ](../sdks/learncard-core/)initialized in your project. + - You have obtained a **JSON Web Token (JWT)** for authentication. This JWT represents the authenticated user (the "actor"). As an example of how to create this JWT, check out the[ "Create a Connected Website Tutorial."](create-a-connected-website.md) + - You have the **DID** of the authenticated user. + - The default LearnCloud xAPI endpoint is `https://cloud.learncard.com/xapi/statements`. -*** +--- ## Part 1: Sending an xAPI Statement @@ -29,6 +29,7 @@ Let's send a statement indicating a user has attempted a challenge in a game. {% stepper %} {% step %} + ### **Define Your xAPI Statement** An xAPI statement has three main parts: an `actor` (who did it), a `verb` (what they did), and an `object` (what they did it to). @@ -36,50 +37,50 @@ An xAPI statement has three main parts: an `actor` (who did it), a `verb` (what ```typescript // Placeholders: Replace with your actual data const userDid = 'did:example:YOUR_USER_DID'; // The DID of the user performing the action -const jwtToken = 'YOUR_JWT_TOKEN'; // Your authentication JWT +const jwtToken = 'YOUR_JWT_TOKEN'; // Your authentication JWT const xapiEndpoint = 'https://cloud.learncard.com/xapi/statements'; const attemptStatement = { actor: { - objectType: "Agent", - name: userDid, // Use the user's DID here + objectType: 'Agent', + name: userDid, // Use the user's DID here account: { - homePage: "https://www.w3.org/TR/did-core/", // Standard homepage for DID accounts - name: userDid // Crucial: Also use the user's DID here - } + homePage: 'https://www.w3.org/TR/did-core/', // Standard homepage for DID accounts + name: userDid, // Crucial: Also use the user's DID here + }, }, verb: { - id: "http://adlnet.gov/expapi/verbs/attempted", // A standard xAPI verb URI + id: 'http://adlnet.gov/expapi/verbs/attempted', // A standard xAPI verb URI display: { - "en-US": "attempted" // Human-readable display for the verb - } + 'en-US': 'attempted', // Human-readable display for the verb + }, }, object: { - id: "http://yourgame.com/activities/level-1-challenge", // A unique URI for your activity + id: 'http://yourgame.com/activities/level-1-challenge', // A unique URI for your activity definition: { - name: { "en-US": "Level 1 Challenge" }, - description: { "en-US": "The first exciting challenge of the game." }, - type: "http://adlnet.gov/expapi/activities/simulation" // Type of activity - } - } + name: { 'en-US': 'Level 1 Challenge' }, + description: { 'en-US': 'The first exciting challenge of the game.' }, + type: 'http://adlnet.gov/expapi/activities/simulation', // Type of activity + }, + }, }; // Type interface for clarity (optional, but good practice) interface XAPIStatement { actor: { - objectType: "Agent"; + objectType: 'Agent'; name: string; - account: { homePage: string; name: string; }; + account: { homePage: string; name: string }; }; verb: { id: string; - display: { "en-US": string; }; + display: { 'en-US': string }; }; object: { id: string; definition: { - name: { "en-US": string }; - description: { "en-US": string }; + name: { 'en-US': string }; + description: { 'en-US': string }; type: string; }; }; @@ -89,10 +90,10 @@ interface XAPIStatement { ✨ **Good to know:** -* **DID Usage:** For LearnCloud, ensure the `userDid` is used in both `actor.name` and `actor.account.name`. -* **Verb Selection:** Use standard xAPI verb URIs when possible. You can find lists of common verbs online (e.g., on the ADLNet website). -* **Activity IDs:** Make your `object.id` URIs unique for each distinct activity. They don't need to be real, live URLs. -{% endstep %} +- **DID Usage:** For LearnCloud, ensure the `userDid` is used in both `actor.name` and `actor.account.name`. +- **Verb Selection:** Use standard xAPI verb URIs when possible. You can find lists of common verbs online (e.g., on the ADLNet website). +- **Activity IDs:** Make your `object.id` URIs unique for each distinct activity. They don't need to be real, live URLs. + {% endstep %} {% step %} **Prepare and Send the Statement** @@ -101,7 +102,7 @@ We'll use a `Workspace` request to send this statement. The key things are the ` ```typescript async function sendStatement(statement: XAPIStatement, token: string, endpointUrl: string) { - console.log("Sending xAPI Statement:", JSON.stringify(statement, null, 2)); + console.log('Sending xAPI Statement:', JSON.stringify(statement, null, 2)); try { const response = await fetch(endpointUrl, { @@ -109,9 +110,9 @@ async function sendStatement(statement: XAPIStatement, token: string, endpointUr headers: { 'Content-Type': 'application/json', 'X-Experience-API-Version': '1.0.3', // Standard xAPI version header - 'X-VP': token // LearnCloud specific: Your JWT for authentication + 'X-VP': token, // LearnCloud specific: Your JWT for authentication }, - body: JSON.stringify(statement) + body: JSON.stringify(statement), }); if (!response.ok) { @@ -123,14 +124,15 @@ async function sendStatement(statement: XAPIStatement, token: string, endpointUr errorData = { status: response.status, statusText: response.statusText }; // Fallback if no JSON body } console.error('xAPI Statement Error:', errorData); - throw new Error(`Failed to send xAPI statement: ${response.status} ${response.statusText}`); + throw new Error( + `Failed to send xAPI statement: ${response.status} ${response.statusText}` + ); } // If successful, the LRS usually returns an array with the ID of the stored statement const responseData = await response.json(); console.log('xAPI Statement Sent Successfully! Response:', responseData); return responseData; // This often is an array with the statement ID(s) - } catch (networkError) { console.error('Network or other error sending xAPI statement:', networkError); throw networkError; @@ -142,7 +144,7 @@ async function sendStatement(statement: XAPIStatement, token: string, endpointUr sendStatement(attemptStatement, jwtToken, xapiEndpoint) .then(ids => { if (ids && ids.length > 0) { - console.log("Statement ID received:", ids[0]); + console.log('Statement ID received:', ids[0]); // You might want to store this ID if you plan to void the statement later. } }) @@ -150,6 +152,7 @@ sendStatement(attemptStatement, jwtToken, xapiEndpoint) // Error already logged in sendStatement, but you can do more here if needed }); ``` + {% endstep %} {% step %} @@ -159,7 +162,7 @@ If successful, the LearnCloud Storage API will typically return an HTTP status l {% endstep %} {% endstepper %} -*** +--- ## Part 2: Reading xAPI Statements @@ -167,6 +170,7 @@ Now that we've sent a statement, let's try to read statements for that user. {% stepper %} {% step %} + ### **Prepare Your Request Parameters** To read statements, you'll usually query for statements related to a specific `agent` (the actor). The `agent` parameter must be a JSON string. @@ -175,24 +179,24 @@ To read statements, you'll usually query for statements related to a specific `a // (Ensure userDid and jwtToken are defined as in Part 1, Step 1) // And xapiEndpoint is also defined: const xapiEndpoint = 'https://cloud.learncard.com/xapi/statements'; - // Define the actor (agent) for whom you want to retrieve statements const actorToQuery = { - objectType: "Agent", + objectType: 'Agent', name: userDid, account: { - homePage: "https://www.w3.org/TR/did-core/", - name: userDid - } + homePage: 'https://www.w3.org/TR/did-core/', + name: userDid, + }, }; // Construct URL parameters const params = new URLSearchParams({ - agent: JSON.stringify(actorToQuery) // Key parameter: filter by agent + agent: JSON.stringify(actorToQuery), // Key parameter: filter by agent // You can add other parameters like 'verb', 'activity', 'since', 'limit' here // For example: limit: '10' }); ``` + {% endstep %} {% step %} @@ -210,8 +214,8 @@ async function readStatements(queryParams: URLSearchParams, token: string, endpo headers: { // 'Content-Type': 'application/json', // Not strictly needed for GET, but often included 'X-Experience-API-Version': '1.0.3', - 'X-VP': token // Your authentication JWT - } + 'X-VP': token, // Your authentication JWT + }, }); if (!response.ok) { @@ -222,13 +226,14 @@ async function readStatements(queryParams: URLSearchParams, token: string, endpo errorData = { status: response.status, statusText: response.statusText }; } console.error('Error Reading xAPI Statements:', errorData); - throw new Error(`Failed to read xAPI statements: ${response.status} ${response.statusText}`); + throw new Error( + `Failed to read xAPI statements: ${response.status} ${response.statusText}` + ); } const data = await response.json(); console.log('Successfully Read xAPI Statements:', data); return data; // Contains 'statements' array and possibly a 'more' link for pagination - } catch (networkError) { console.error('Network or other error reading xAPI statements:', networkError); throw networkError; @@ -243,21 +248,24 @@ readStatements(params, jwtToken, xapiEndpoint) console.log(`Found ${data.statements.length} statement(s).`); // You can now iterate through data.statements // Try to find the statement you sent earlier! - const myStatement = data.statements.find(stmt => stmt.object.id === "http://yourgame.com/activities/level-1-challenge"); + const myStatement = data.statements.find( + stmt => stmt.object.id === 'http://yourgame.com/activities/level-1-challenge' + ); if (myStatement) { - console.log("Found the statement we sent:", myStatement); + console.log('Found the statement we sent:', myStatement); } } else { - console.log("No statements found for this agent or an error occurred."); + console.log('No statements found for this agent or an error occurred.'); } if (data.more) { - console.log("More statements available at:", data.more); + console.log('More statements available at:', data.more); } }) .catch(error => { // Error already logged }); ``` + {% endstep %} {% step %} @@ -267,19 +275,20 @@ The response from a `GET` request to the `/statements` endpoint will be a JSON o {% endstep %} {% endstepper %} -*** +--- ## Important Considerations (Recap) -* **Authentication (`X-VP` Header):** All requests to the LearnCloud xAPI endpoint must include a valid JWT in the `X-VP` header. -* **Permissions:** - * Users can only send statements where they are the actor (or have delegated authority). - * Users can typically only read statements where they are the actor. The DID in your JWT (`X-VP` header) must match the actor's DID you are querying for. A `401 Unauthorized` error often means a DID mismatch or an invalid/expired JWT. -* **Error Handling:** Always check response statuses and handle potential errors from the API or network issues. -* **Delegated Access:** For scenarios where another party needs to read statements, LearnCloud supports a delegated access mechanism using Verifiable Credentials. (See Advanced Topics: Delegated Access for more info). -* **Voiding Statements:** You can invalidate previously sent statements. (See [Advanced Topics: Voiding Statements](../sdks/learncloud-storage-api/xapi-reference.md#voiding-statements) for how). +- **Authentication (`X-VP` Header):** All requests to the LearnCloud xAPI endpoint must include a valid JWT in the `X-VP` header. +- **Permissions:** + - Users can only send statements where they are the actor (or have delegated authority). + - Users can typically only read statements where they are the actor. The DID in your JWT (`X-VP` header) must match the actor's DID you are querying for. A `401 Unauthorized` error often means a DID mismatch or an invalid/expired JWT. +- **Error Handling:** Always check response statuses and handle potential errors from the API or network issues. +- **Delegated Access:** For scenarios where another party needs to read or write statements on behalf of a user, LearnCloud supports a delegated access mechanism using Verifiable Credentials. (See [Delegated Access](../sdks/learncloud-storage-api/xapi-reference.md#delegated-access) for more info). +- **Contract-Scoped Statements:** When using ConsentFlow, xAPI statements can be automatically tagged with the contract URI, enabling queries by contract. (See [Contract-Scoped xAPI Statements](../sdks/learncloud-storage-api/xapi-reference.md#contract-scoped-xapi-statements) for details). +- **Voiding Statements:** You can invalidate previously sent statements. (See [Advanced Topics: Voiding Statements](../sdks/learncloud-storage-api/xapi-reference.md#voiding-statements) for how). -*** +--- ## Next Steps @@ -287,9 +296,9 @@ Congratulations! You've now seen how to send and read basic xAPI statements with From here, you can explore: -* Sending different types of xAPI statements (e.g., `completed`, `mastered`, with `result` objects). -* Using the other examples provided in our [xAPI Concepts Guide](../core-concepts/credentials-and-data/xapi-data.md). -* Implementing more advanced queries to filter and retrieve statements. (See [Advanced xAPI Statement Queries](../sdks/learncloud-storage-api/xapi-reference.md#advanced-xapi-statement-queries)). -* Connecting these xAPI statements as evidence for Verifiable Credentials. +- Sending different types of xAPI statements (e.g., `completed`, `mastered`, with `result` objects). +- Using the other examples provided in our [xAPI Concepts Guide](../core-concepts/credentials-and-data/xapi-data.md). +- Implementing more advanced queries to filter and retrieve statements. (See [Advanced xAPI Statement Queries](../sdks/learncloud-storage-api/xapi-reference.md#advanced-xapi-statement-queries)). +- Connecting these xAPI statements as evidence for Verifiable Credentials. Happy tracking!