@learncard/cli 3.4.17 → 3.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +10 -0
- package/dist/index.js +1933 -56
- package/package.json +24 -24
- package/rollup.config.js +25 -3
- package/scripts/embed-snippets.ts +25 -0
- package/src/consent-contract.ts +116 -0
- package/src/embed.ts +102 -0
- package/src/index.tsx +473 -147
- package/src/init.ts +26 -0
- package/src/open.test.ts +40 -0
- package/src/open.ts +186 -0
- package/src/out.test.ts +40 -0
- package/src/out.ts +16 -0
- package/src/phase-two.test.ts +154 -0
- package/src/project.test.ts +207 -0
- package/src/project.ts +394 -0
- package/src/revoke.test.ts +29 -0
- package/src/revoke.ts +65 -0
- package/src/send-template.test.ts +20 -0
- package/src/send.test.ts +45 -0
- package/src/send.ts +245 -0
- package/src/setup-signing.test.ts +62 -0
- package/src/setup-signing.ts +185 -0
- package/src/snippet-files.ts +16 -0
- package/src/status.test.ts +33 -0
- package/src/status.ts +96 -0
- package/src/token.test.ts +28 -0
- package/src/token.ts +138 -0
- package/src/verify.test.ts +35 -0
- package/src/verify.ts +66 -0
- package/src/webhook.ts +247 -0
- package/tsconfig.json +1 -0
package/package.json
CHANGED
|
@@ -1,40 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.1",
|
|
4
4
|
"description": "Command Line Interface for LearnCard",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": "dist/index.js",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"start": "bun --conditions=development src/index.tsx",
|
|
9
|
-
"start:built": "
|
|
10
|
-
"dev": "bun
|
|
11
|
-
"build": "rollup -c",
|
|
12
|
-
"test": "vitest run",
|
|
13
|
-
"test:watch": "vitest",
|
|
14
|
-
"test:coverage": "vitest run --coverage"
|
|
8
|
+
"start": "bun scripts/embed-snippets.ts && bun --conditions=development src/index.tsx",
|
|
9
|
+
"start:built": "bun run build && node --enable-source-maps dist/index.js",
|
|
10
|
+
"dev": "bun run start",
|
|
11
|
+
"build": "bun scripts/embed-snippets.ts && rollup -c",
|
|
12
|
+
"test": "bun scripts/embed-snippets.ts && vitest run",
|
|
13
|
+
"test:watch": "bun scripts/embed-snippets.ts && vitest",
|
|
14
|
+
"test:coverage": "bun scripts/embed-snippets.ts && vitest run --coverage"
|
|
15
15
|
},
|
|
16
16
|
"author": "Learning Economy Foundation (www.learningeconomy.io)",
|
|
17
17
|
"license": "MIT",
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@learncard/core": "9.4.
|
|
20
|
-
"@learncard/didkit-plugin": "^1.
|
|
21
|
-
"@learncard/init": "^2.4.
|
|
22
|
-
"@learncard/lca-api-plugin": "2.0.
|
|
23
|
-
"@learncard/learn-cloud-plugin": "2.3.
|
|
24
|
-
"@learncard/ler-rs-plugin": "0.1.
|
|
25
|
-
"@learncard/linked-claims-plugin": "0.2.
|
|
26
|
-
"@learncard/open-badge-v2-plugin": "1.1.
|
|
27
|
-
"@learncard/render-method-plugin": "
|
|
28
|
-
"@learncard/holder-continuity": "0.2.
|
|
29
|
-
"@learncard/types": "5.
|
|
30
|
-
"@rollup/plugin-json": "^4.1.0",
|
|
19
|
+
"@learncard/core": "9.4.35",
|
|
20
|
+
"@learncard/didkit-plugin": "^1.10.0",
|
|
21
|
+
"@learncard/init": "^2.4.16",
|
|
22
|
+
"@learncard/lca-api-plugin": "2.0.5",
|
|
23
|
+
"@learncard/learn-cloud-plugin": "2.3.41",
|
|
24
|
+
"@learncard/ler-rs-plugin": "0.1.26",
|
|
25
|
+
"@learncard/linked-claims-plugin": "0.2.35",
|
|
26
|
+
"@learncard/open-badge-v2-plugin": "1.1.36",
|
|
27
|
+
"@learncard/render-method-plugin": "7.0.0",
|
|
28
|
+
"@learncard/holder-continuity": "0.2.18",
|
|
29
|
+
"@learncard/types": "5.20.0",
|
|
31
30
|
"clipboardy": "^4.0.0",
|
|
32
31
|
"commander": "^9.3.0",
|
|
33
32
|
"figlet": "^1.5.2",
|
|
34
33
|
"gradient-string": "^2.0.1",
|
|
35
|
-
"pretty-repl": "^3.1.1"
|
|
36
|
-
"rollup": "^2.80.0",
|
|
37
|
-
"rollup-plugin-esbuild": "^4.9.1"
|
|
34
|
+
"pretty-repl": "^3.1.1"
|
|
38
35
|
},
|
|
39
36
|
"devDependencies": {
|
|
40
37
|
"@types/cors": "^2.8.12",
|
|
@@ -42,7 +39,10 @@
|
|
|
42
39
|
"@types/node": "^18.0.0",
|
|
43
40
|
"nodemon": "^2.0.16",
|
|
44
41
|
"ts-node": "^10.8.1",
|
|
45
|
-
"vitest": "4.1.
|
|
42
|
+
"vitest": "4.1.11",
|
|
43
|
+
"@rollup/plugin-json": "^4.1.0",
|
|
44
|
+
"rollup": "^2.80.0",
|
|
45
|
+
"rollup-plugin-esbuild": "^4.9.1"
|
|
46
46
|
},
|
|
47
47
|
"repository": {
|
|
48
48
|
"type": "git",
|
package/rollup.config.js
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
import json from '@rollup/plugin-json';
|
|
2
2
|
import esbuild from 'rollup-plugin-esbuild';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
const packageJson = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
5
7
|
|
|
6
8
|
export default [
|
|
7
9
|
{
|
|
8
10
|
input: ['src/index.tsx'],
|
|
9
|
-
output: [
|
|
10
|
-
|
|
11
|
+
output: [
|
|
12
|
+
{
|
|
13
|
+
file: packageJson.bin,
|
|
14
|
+
format: 'cjs',
|
|
15
|
+
banner: '#!/usr/bin/env node',
|
|
16
|
+
inlineDynamicImports: true,
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
plugins: [
|
|
20
|
+
json(),
|
|
21
|
+
esbuild(),
|
|
22
|
+
{
|
|
23
|
+
name: 'native-webhook-import',
|
|
24
|
+
renderDynamicImport({ moduleId }) {
|
|
25
|
+
// Rollup 2 otherwise emits require(data:...), which Node cannot load.
|
|
26
|
+
if (moduleId.endsWith('/src/webhook.ts')) {
|
|
27
|
+
return { left: 'import(', right: ')' };
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
],
|
|
11
33
|
},
|
|
12
34
|
];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
6
|
+
const snippets = {
|
|
7
|
+
SEND_MJS: 'quickstart/send.mjs',
|
|
8
|
+
SEND_SH: 'quickstart/send.sh',
|
|
9
|
+
SEND_FROM_TEMPLATE_MJS: 'quickstart/send-from-template.mjs',
|
|
10
|
+
WEBHOOK_MJS: 'cli/webhook.mjs',
|
|
11
|
+
CLAIM_BUTTON_HTML: 'cli/claim-button.html',
|
|
12
|
+
CREATE_CONTRACT_MJS: 'consentflow/create-contract.mjs',
|
|
13
|
+
CONSENT_CALLBACK_MJS: 'consentflow/consent-callback.mjs',
|
|
14
|
+
READ_USER_DATA_MJS: 'consentflow/read-user-data.mjs',
|
|
15
|
+
ISSUE_THROUGH_CONTRACT_MJS: 'consentflow/issue-through-contract.mjs',
|
|
16
|
+
};
|
|
17
|
+
const lines = await Promise.all(
|
|
18
|
+
Object.entries(snippets).map(
|
|
19
|
+
async ([name, file]) =>
|
|
20
|
+
`export const ${name} = ${JSON.stringify(await readFile(resolve(root, 'docs/snippets', file), 'utf8'))};\n`
|
|
21
|
+
)
|
|
22
|
+
);
|
|
23
|
+
const output = resolve(root, 'packages/learn-card-cli/src/generated/snippets.ts');
|
|
24
|
+
await mkdir(dirname(output), { recursive: true });
|
|
25
|
+
await writeFile(output, '// Generated from docs/snippets. Do not edit.\n' + lines.join(''));
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
connect,
|
|
3
|
+
createPrompts,
|
|
4
|
+
ensureIdentity,
|
|
5
|
+
ensureProfile,
|
|
6
|
+
KEYS,
|
|
7
|
+
loadProject,
|
|
8
|
+
localizeSnippet,
|
|
9
|
+
resolveServices,
|
|
10
|
+
saveProject,
|
|
11
|
+
STAGING_NETWORK,
|
|
12
|
+
PRODUCTION_NETWORK,
|
|
13
|
+
type ProjectOptions,
|
|
14
|
+
appUrlFor,
|
|
15
|
+
} from './project';
|
|
16
|
+
import {
|
|
17
|
+
CREATE_CONTRACT_MJS,
|
|
18
|
+
CONSENT_CALLBACK_MJS,
|
|
19
|
+
READ_USER_DATA_MJS,
|
|
20
|
+
ISSUE_THROUGH_CONTRACT_MJS,
|
|
21
|
+
} from './generated/snippets';
|
|
22
|
+
import { writeSnippet } from './snippet-files';
|
|
23
|
+
import { out } from './out';
|
|
24
|
+
|
|
25
|
+
export const consentUrl = (uri: string, returnTo: string, network: string): string => {
|
|
26
|
+
const url = new URL('/consent-flow', appUrlFor(network));
|
|
27
|
+
url.searchParams.set('uri', uri);
|
|
28
|
+
url.searchParams.set('returnTo', returnTo);
|
|
29
|
+
return url.toString();
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const runConsentContract = async (
|
|
33
|
+
options: ProjectOptions & {
|
|
34
|
+
redirectUrl?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
needsGuardianConsent?: boolean;
|
|
37
|
+
}
|
|
38
|
+
): Promise<void> => {
|
|
39
|
+
const project = await loadProject(process.cwd());
|
|
40
|
+
const returnTo =
|
|
41
|
+
options.redirectUrl || project.env.RETURN_TO || 'http://localhost:3000/consent-callback';
|
|
42
|
+
if (!['http:', 'https:'].includes(new URL(returnTo).protocol)) {
|
|
43
|
+
throw new Error('Redirect URL must use HTTP or HTTPS.');
|
|
44
|
+
}
|
|
45
|
+
const identity = await ensureIdentity(project, options);
|
|
46
|
+
const learnCard = await connect(project, options);
|
|
47
|
+
await ensureProfile(learnCard, identity, project);
|
|
48
|
+
let uri = project.env[KEYS.CONTRACT_URI];
|
|
49
|
+
if (!uri) {
|
|
50
|
+
const prompts = createPrompts(options.yes);
|
|
51
|
+
let name: string;
|
|
52
|
+
try {
|
|
53
|
+
name = options.name ?? (await prompts.ask('Contract name', identity.displayName));
|
|
54
|
+
} finally {
|
|
55
|
+
prompts.close();
|
|
56
|
+
}
|
|
57
|
+
const contract = {
|
|
58
|
+
name,
|
|
59
|
+
description:
|
|
60
|
+
options.description ??
|
|
61
|
+
'Allow us to read your name and achievements and send achievements.',
|
|
62
|
+
...(options.needsGuardianConsent ? { needsGuardianConsent: true } : {}),
|
|
63
|
+
redirectUrl: returnTo,
|
|
64
|
+
contract: {
|
|
65
|
+
read: {
|
|
66
|
+
personal: { name: { required: false } },
|
|
67
|
+
credentials: { categories: { Achievement: { required: false } } },
|
|
68
|
+
},
|
|
69
|
+
write: {
|
|
70
|
+
personal: {},
|
|
71
|
+
credentials: { categories: { Achievement: { required: false } } },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
uri = await learnCard.invoke.createContract(contract);
|
|
76
|
+
out.log('Created your consent contract.');
|
|
77
|
+
} else {
|
|
78
|
+
out.log('Reusing your saved consent contract.');
|
|
79
|
+
}
|
|
80
|
+
await saveProject(project, { [KEYS.CONTRACT_URI]: uri, RETURN_TO: returnTo });
|
|
81
|
+
const services = resolveServices(project.env, options.network);
|
|
82
|
+
const files: string[] = [];
|
|
83
|
+
for (const [file, source] of Object.entries({
|
|
84
|
+
'create-contract.mjs': CREATE_CONTRACT_MJS,
|
|
85
|
+
'consent-callback.mjs': CONSENT_CALLBACK_MJS,
|
|
86
|
+
'read-user-data.mjs': READ_USER_DATA_MJS,
|
|
87
|
+
'issue-through-contract.mjs': ISSUE_THROUGH_CONTRACT_MJS,
|
|
88
|
+
})) {
|
|
89
|
+
// The canonical tutorial uses a destructured SECURE_SEED; normalize only for localization.
|
|
90
|
+
const localized =
|
|
91
|
+
services.network === PRODUCTION_NETWORK
|
|
92
|
+
? source
|
|
93
|
+
: localizeSnippet(
|
|
94
|
+
source.replaceAll(
|
|
95
|
+
'seed: SECURE_SEED, network: true',
|
|
96
|
+
'seed: process.env.SECURE_SEED, network: true'
|
|
97
|
+
),
|
|
98
|
+
services
|
|
99
|
+
);
|
|
100
|
+
if (await writeSnippet(file, localized)) files.push(`./${file}`);
|
|
101
|
+
}
|
|
102
|
+
const contractConsentUrl = consentUrl(uri, returnTo, services.network);
|
|
103
|
+
out.log(contractConsentUrl);
|
|
104
|
+
if (![PRODUCTION_NETWORK, STAGING_NETWORK].includes(services.network)) {
|
|
105
|
+
out.log(
|
|
106
|
+
'Note: this is a production app URL; use an app connected to your local network to consent.'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
out.log(
|
|
110
|
+
'The tutorial create-contract.mjs creates a new contract and requires HTTPS; reuse CONTRACT_URI for this dev setup.'
|
|
111
|
+
);
|
|
112
|
+
out.log('Read scripts require a verified CONSENT_VP, not a bare DID.');
|
|
113
|
+
out.log('Next: node --env-file=.env consent-callback.mjs');
|
|
114
|
+
out.log('See the contract in the app: npx @learncard/cli open contract');
|
|
115
|
+
out.set({ contractUri: uri, consentUrl: contractConsentUrl, returnTo, files });
|
|
116
|
+
};
|
package/src/embed.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { LCNDomainOrOriginValidator } from '@learncard/types';
|
|
2
|
+
import {
|
|
3
|
+
connect,
|
|
4
|
+
ensureIdentity,
|
|
5
|
+
ensureProfile,
|
|
6
|
+
KEYS,
|
|
7
|
+
loadProject,
|
|
8
|
+
resolveServices,
|
|
9
|
+
saveProject,
|
|
10
|
+
type ProjectOptions,
|
|
11
|
+
} from './project';
|
|
12
|
+
import { setupSigning } from './setup-signing';
|
|
13
|
+
import { CLAIM_BUTTON_HTML } from './generated/snippets';
|
|
14
|
+
import { writeSnippet } from './snippet-files';
|
|
15
|
+
import { out } from './out';
|
|
16
|
+
|
|
17
|
+
export const validateDomains = (domains: string): string[] =>
|
|
18
|
+
domains.split(',').map(domain => {
|
|
19
|
+
const result = LCNDomainOrOriginValidator.safeParse(domain.trim());
|
|
20
|
+
if (!result.success)
|
|
21
|
+
throw new Error(result.error.format()._errors.join('; ') || result.error.message);
|
|
22
|
+
return result.data;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const scriptString = (value: string): string => JSON.stringify(value).replace(/</g, '\\u003c');
|
|
26
|
+
|
|
27
|
+
export const personalizeClaimButton = (key: string, apiBaseUrl: string): string =>
|
|
28
|
+
CLAIM_BUTTON_HTML.replace("'PUBLISHABLE_KEY_PLACEHOLDER'", () => scriptString(key)).replace(
|
|
29
|
+
"'https://network.learncard.com/api'",
|
|
30
|
+
() => scriptString(apiBaseUrl)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const runEmbed = async (
|
|
34
|
+
options: ProjectOptions & { domains?: string; rotateKey?: boolean }
|
|
35
|
+
): Promise<void> => {
|
|
36
|
+
const domains = validateDomains(
|
|
37
|
+
options.domains ?? 'http://localhost:3000,http://localhost:5173'
|
|
38
|
+
);
|
|
39
|
+
const project = await loadProject(process.cwd());
|
|
40
|
+
const identity = await ensureIdentity(project, options);
|
|
41
|
+
const learnCard = await connect(project, { ...options, lca: true });
|
|
42
|
+
await ensureProfile(learnCard, identity, project);
|
|
43
|
+
await setupSigning(project, learnCard);
|
|
44
|
+
let id = project.env[KEYS.INTEGRATION_ID];
|
|
45
|
+
const reused = Boolean(id);
|
|
46
|
+
if (!id) {
|
|
47
|
+
id = await learnCard.invoke.addIntegration({
|
|
48
|
+
name: options.name ?? identity.displayName,
|
|
49
|
+
whitelistedDomains: domains,
|
|
50
|
+
guideType: 'embed-claim',
|
|
51
|
+
});
|
|
52
|
+
// Persist immediately so a later failed read/rotation never duplicates the integration.
|
|
53
|
+
await saveProject(project, { [KEYS.INTEGRATION_ID]: id });
|
|
54
|
+
}
|
|
55
|
+
let integration = await learnCard.invoke.getIntegration(id);
|
|
56
|
+
if (!integration) throw new Error('Saved integration was not found. Check INTEGRATION_ID.');
|
|
57
|
+
if (options.rotateKey) {
|
|
58
|
+
if (!(await learnCard.invoke.updateIntegration(id, { rotatePublishableKey: true }))) {
|
|
59
|
+
throw new Error('Could not rotate the publishable key.');
|
|
60
|
+
}
|
|
61
|
+
integration = await learnCard.invoke.getIntegration(id);
|
|
62
|
+
if (!integration) throw new Error('Could not read the rotated integration.');
|
|
63
|
+
}
|
|
64
|
+
await saveProject(project, {
|
|
65
|
+
[KEYS.INTEGRATION_ID]: id,
|
|
66
|
+
[KEYS.PUBLISHABLE_KEY]: integration.publishableKey,
|
|
67
|
+
});
|
|
68
|
+
const apiBase = resolveServices(project.env, options.network).network.replace(
|
|
69
|
+
/\/trpc\/?$/,
|
|
70
|
+
'/api'
|
|
71
|
+
);
|
|
72
|
+
const wroteClaimButton = await writeSnippet(
|
|
73
|
+
'claim-button.html',
|
|
74
|
+
personalizeClaimButton(integration.publishableKey, apiBase)
|
|
75
|
+
);
|
|
76
|
+
out.log(`Publishable key: ${integration.publishableKey}`);
|
|
77
|
+
out.log(`Whitelisted origins: ${integration.whitelistedDomains.join(', ')}`);
|
|
78
|
+
if (options.domains && reused) {
|
|
79
|
+
out.log(
|
|
80
|
+
'Existing integrations keep their saved origins; change them in the Developer Portal.'
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (options.rotateKey)
|
|
84
|
+
out.log('Update the publishable key in any existing claim-button.html and deployed pages.');
|
|
85
|
+
const firstDomain = integration.whitelistedDomains[0];
|
|
86
|
+
const origin = firstDomain?.startsWith('http')
|
|
87
|
+
? firstDomain
|
|
88
|
+
: firstDomain
|
|
89
|
+
? `http://${firstDomain}`
|
|
90
|
+
: undefined;
|
|
91
|
+
out.log(
|
|
92
|
+
origin
|
|
93
|
+
? `Next: Open claim-button.html from ${origin} (a whitelisted origin), not file://.\nSee the integration in the app: npx @learncard/cli open integration`
|
|
94
|
+
: 'Next: add a whitelisted origin in the Developer Portal, then serve claim-button.html there, not file://.'
|
|
95
|
+
);
|
|
96
|
+
out.set({
|
|
97
|
+
integrationId: id,
|
|
98
|
+
publishableKey: integration.publishableKey,
|
|
99
|
+
whitelistedDomains: integration.whitelistedDomains,
|
|
100
|
+
files: wroteClaimButton ? ['./claim-button.html'] : [],
|
|
101
|
+
});
|
|
102
|
+
};
|