@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/src/token.ts ADDED
@@ -0,0 +1,138 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ connect,
5
+ ensureIdentity,
6
+ ensureProfile,
7
+ loadProject,
8
+ saveProject,
9
+ type ProjectOptions,
10
+ localizeSnippet,
11
+ resolveServices,
12
+ withEnvTokenLoader,
13
+ } from './project';
14
+ import { SEND_SH } from './generated/snippets';
15
+ import { out } from './out';
16
+
17
+ // Route-derived resources documented in auth-grants-and-api-tokens.md (not the stale singular aliases).
18
+ export const SCOPE_RESOURCES = [
19
+ 'boosts',
20
+ 'inbox',
21
+ 'credentials',
22
+ 'presentations',
23
+ 'profiles',
24
+ 'profileManagers',
25
+ 'connections',
26
+ 'contracts',
27
+ 'contracts-data',
28
+ 'signingAuthorities',
29
+ 'authGrants',
30
+ 'didMetadata',
31
+ 'claimHooks',
32
+ 'skills',
33
+ 'app-store',
34
+ 'integrations',
35
+ 'contact-methods',
36
+ 'activity',
37
+ 'storage',
38
+ ] as const;
39
+
40
+ export const validateScope = (scope: string): string => {
41
+ for (const part of scope.trim().split(/\s+/).filter(Boolean)) {
42
+ const [resource, action, extra] = part.split(':');
43
+ if (
44
+ extra !== undefined ||
45
+ !resource ||
46
+ (resource !== '*' && !SCOPE_RESOURCES.some(value => value === resource))
47
+ ) {
48
+ throw new Error(
49
+ `Unknown scope resource in "${part}". Choose: ${SCOPE_RESOURCES.join(', ')}, or *.`
50
+ );
51
+ }
52
+ if (!action || !['read', 'write', 'delete', '*'].includes(action)) {
53
+ throw new Error(`Invalid action in "${part}". Use read, write, delete, or *.`);
54
+ }
55
+ }
56
+ return scope.trim().split(/\s+/).filter(Boolean).join(' ');
57
+ };
58
+
59
+ export const runToken = async (
60
+ options: ProjectOptions & { scope?: string; revoke?: string; expires?: string; list?: boolean }
61
+ ): Promise<void> => {
62
+ const scope = validateScope(options.scope ?? 'boosts:write');
63
+ const project = await loadProject(process.cwd());
64
+ const identity = await ensureIdentity(project, { ...options, name: undefined });
65
+ const learnCard = await connect(project, options);
66
+ await ensureProfile(learnCard, identity, project);
67
+ if (options.list) {
68
+ const grants = (await learnCard.invoke.getAuthGrants()) ?? [];
69
+ for (const g of grants)
70
+ out.log(
71
+ `${g.id} ${(g.status ?? '').padEnd(8)} ${(g.scope ?? '').padEnd(22)} ${g.name ?? ''}${
72
+ g.expiresAt ? ` expires ${g.expiresAt.slice(0, 10)}` : ''
73
+ }`
74
+ );
75
+ if (!grants.length) out.log('No auth grants yet.');
76
+ out.set({
77
+ grants: grants.map(g => ({
78
+ id: g.id,
79
+ name: g.name,
80
+ scope: g.scope,
81
+ status: g.status,
82
+ expiresAt: g.expiresAt,
83
+ })),
84
+ });
85
+ return;
86
+ }
87
+ if (options.revoke) {
88
+ if (!(await learnCard.invoke.revokeAuthGrant(options.revoke)))
89
+ throw new Error('Could not revoke auth grant.');
90
+ out.log(`Revoked auth grant ${options.revoke}.`);
91
+ out.log('Create a replacement: npx @learncard/cli token');
92
+ out.set({ grantId: options.revoke });
93
+ return;
94
+ }
95
+ const id = await learnCard.invoke.addAuthGrant({
96
+ name: options.name ?? `cli-${new Date().toISOString().slice(0, 10)}`,
97
+ scope,
98
+ ...(options.expires
99
+ ? {
100
+ expiresAt: new Date(
101
+ Date.now() + Number(options.expires) * 86_400_000
102
+ ).toISOString(),
103
+ }
104
+ : {}),
105
+ });
106
+ const token = await learnCard.invoke.getAPITokenForAuthGrant(id);
107
+ // Tighten existing files before writing the bearer credential.
108
+ await fs.chmod(project.envPath, 0o600);
109
+ await saveProject(project, { API_TOKEN: token, API_TOKEN_SCOPE: scope });
110
+ out.log(`Created auth grant ${id}.`);
111
+ out.log(
112
+ "Warning: this token won't be shown again by this command. It is saved in .env; keep it private."
113
+ );
114
+ out.log(token);
115
+ const file = path.join(process.cwd(), 'send.sh');
116
+ let wroteSendSh = false;
117
+ try {
118
+ await fs.writeFile(
119
+ file,
120
+ withEnvTokenLoader(
121
+ localizeSnippet(SEND_SH, resolveServices(project.env, options.network))
122
+ ),
123
+ {
124
+ flag: 'wx',
125
+ mode: 0o700,
126
+ }
127
+ );
128
+ out.log('Wrote ./send.sh');
129
+ wroteSendSh = true;
130
+ } catch (error) {
131
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
132
+ out.log('Kept existing ./send.sh');
133
+ }
134
+ out.log(
135
+ 'Next: save your send payload as request.json, then run sh ./send.sh (example: https://docs.learncard.com/start-here/your-first-integration).'
136
+ );
137
+ out.set({ grantId: id, scope, token, files: wroteSendSh ? ['./send.sh'] : [] });
138
+ };
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatVerification, isPresentation, verificationFailed } from './verify';
3
+
4
+ describe('verification output', () => {
5
+ it('formats raw checks and errors', () => {
6
+ const result = {
7
+ checks: ['expiration'],
8
+ errors: ['signature error: Bad signature'],
9
+ warnings: ['Review issuer'],
10
+ };
11
+ expect(formatVerification(result)).toEqual([
12
+ '✓ expiration',
13
+ '! Review issuer',
14
+ '✗ proof: Bad signature',
15
+ ]);
16
+ expect(verificationFailed(result)).toBe(true);
17
+ });
18
+ it('keeps warnings distinct from failures in prettified output', () => {
19
+ const result = [
20
+ { status: 'Success' as const, check: 'proof', message: 'Valid' },
21
+ { status: 'Error' as const, check: 'issuer', message: 'Not checked' },
22
+ ];
23
+ expect(formatVerification(result)).toEqual(['✓ proof', '! issuer: Not checked']);
24
+ expect(verificationFailed(result)).toBe(false);
25
+ expect(verificationFailed([{ status: 'Failed', check: 'proof', details: 'Invalid' }])).toBe(
26
+ true
27
+ );
28
+ });
29
+ it('detects presentations by string or array type', () => {
30
+ expect(isPresentation({ type: ['VerifiablePresentation'] })).toBe(true);
31
+ expect(isPresentation({ type: 'VerifiablePresentation' })).toBe(true);
32
+ expect(isPresentation({ type: ['VerifiableCredential'] })).toBe(false);
33
+ expect(isPresentation(null)).toBe(false);
34
+ });
35
+ });
package/src/verify.ts ADDED
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs/promises';
2
+ import { initLearnCard } from '@learncard/init';
3
+ import { type VC, type VP, type VerificationCheck, type VerificationItem } from '@learncard/types';
4
+
5
+ export type VerifyResult = VerificationCheck | VerificationItem[];
6
+
7
+ export const isPresentation = (value: unknown): boolean => {
8
+ if (!value || typeof value !== 'object' || !('type' in value)) return false;
9
+ const types = Array.isArray(value.type) ? value.type : [value.type];
10
+ return types.includes('VerifiablePresentation');
11
+ };
12
+
13
+ const checkName = (value: string): string => (value === 'signature' ? 'proof' : value);
14
+
15
+ export const verificationFailed = (result: VerifyResult): boolean =>
16
+ Array.isArray(result)
17
+ ? result.some(item => item.status === 'Failed')
18
+ : result.errors.length > 0;
19
+
20
+ export const formatVerification = (result: VerifyResult): string[] => {
21
+ if (Array.isArray(result))
22
+ return result.map(item => {
23
+ const check = checkName(item.check);
24
+ if (item.status === 'Success') return `✓ ${check}`;
25
+ return `${item.status === 'Failed' ? '✗' : '!'} ${check}: ${item.details || item.message || 'Check failed'}`;
26
+ });
27
+ return [
28
+ ...result.checks.map(check => `✓ ${checkName(check)}`),
29
+ ...result.warnings.map(warning => `! ${warning}`),
30
+ ...result.errors.map(error => {
31
+ const match = error.match(/^(.+?) error:\s*(.*)$/);
32
+ return match ? `✗ ${checkName(match[1]!)}: ${match[2]}` : `✗ ${error}`;
33
+ }),
34
+ ];
35
+ };
36
+
37
+ export const runVerify = async (
38
+ file: string,
39
+ options: { json?: boolean; didkit?: Promise<Buffer> }
40
+ ): Promise<void> => {
41
+ let text: string;
42
+ if (file === '-') {
43
+ const chunks: Buffer[] = [];
44
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
45
+ text = Buffer.concat(chunks).toString('utf8');
46
+ } else text = await fs.readFile(file, 'utf8');
47
+ const input: unknown = JSON.parse(text);
48
+ if (!input || typeof input !== 'object' || !('type' in input))
49
+ throw new Error('Expected a credential or presentation JSON object with a type.');
50
+ const presentation = isPresentation(input);
51
+ const types = Array.isArray(input.type) ? input.type : [input.type];
52
+ if (!presentation && !types.includes('VerifiableCredential'))
53
+ throw new Error('Expected VerifiableCredential or VerifiablePresentation in type.');
54
+ const learnCard = await initLearnCard({ ...(options.didkit && { didkit: options.didkit }) });
55
+ const result = presentation
56
+ ? await learnCard.invoke.verifyPresentation(input as VP)
57
+ : options.json
58
+ ? await learnCard.invoke.verifyCredential(input as VC)
59
+ : await learnCard.invoke.verifyCredential(input as VC, {}, true);
60
+ if (options.json) console.log(JSON.stringify(result, null, 2));
61
+ else {
62
+ for (const line of formatVerification(result)) console.log(line);
63
+ console.log('Next: review warnings and issuer trust before accepting this credential.');
64
+ }
65
+ if (verificationFailed(result)) process.exitCode = 1;
66
+ };
package/src/webhook.ts ADDED
@@ -0,0 +1,247 @@
1
+ import type { Server } from 'node:http';
2
+ import { initLearnCard } from '@learncard/init';
3
+ import {
4
+ connect,
5
+ createPrompts,
6
+ ensureIdentity,
7
+ ensureProfile,
8
+ KEYS,
9
+ loadProject,
10
+ localizeSnippet,
11
+ resolveServices,
12
+ saveProject,
13
+ type ProjectOptions,
14
+ PRODUCTION_NETWORK,
15
+ } from './project';
16
+ import { DEFAULT_BADGE, templateCredential } from './send';
17
+ import { WEBHOOK_MJS } from './generated/snippets';
18
+ import { writeSnippet } from './snippet-files';
19
+ import { setupSigning } from './setup-signing';
20
+ import { out } from './out';
21
+
22
+ interface WebhookModule {
23
+ extractBearer: (header: unknown) => string | undefined;
24
+ webhookDedupeKey: (payload: unknown) => string | undefined;
25
+ createWebhookReceiver: (
26
+ verifier: {
27
+ invoke: {
28
+ verifyPresentation: (
29
+ token: string,
30
+ options: { proofFormat: 'jwt' }
31
+ ) => Promise<{ errors: unknown[] }>;
32
+ };
33
+ },
34
+ expectedDid?: string
35
+ ) => Server;
36
+ }
37
+
38
+ export const webhookConfig = (
39
+ env: Record<string, string>,
40
+ options: { port?: string },
41
+ runtime: Record<string, string | undefined> = process.env
42
+ ): { port: number; expectedDid: string | undefined } => {
43
+ const port = Number(options.port ?? runtime.PORT ?? env.PORT ?? 8787);
44
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
45
+ throw new Error('Port must be 1–65535.');
46
+ return { port, expectedDid: runtime.EXPECTED_NETWORK_DID ?? env.EXPECTED_NETWORK_DID };
47
+ };
48
+
49
+ export const closeWebhookReceiver = (server: Server): Promise<void> =>
50
+ new Promise((resolve, reject) => {
51
+ // Verification may be waiting on a remote DID. Do not let it block Ctrl+C indefinitely.
52
+ const timer = setTimeout(() => server.closeAllConnections(), 1000);
53
+ timer.unref();
54
+ server.close(error => {
55
+ clearTimeout(timer);
56
+ if (error) reject(error);
57
+ else resolve();
58
+ });
59
+ });
60
+
61
+ /** Execute the embedded, canonical receiver, never a possibly edited file from the cwd. */
62
+ export const loadWebhookModule = async (): Promise<WebhookModule> => {
63
+ return import(`data:text/javascript;base64,${Buffer.from(WEBHOOK_MJS).toString('base64')}`);
64
+ };
65
+
66
+ export interface WebhookEvent {
67
+ type: string;
68
+ status?: string;
69
+ issuanceId?: string;
70
+ recipient?: string;
71
+ }
72
+
73
+ /** The embedded receiver logs one space-separated line per event: type, status, issuanceId, recipient DID. */
74
+ export const parseWebhookLogLine = (line: string): WebhookEvent | undefined => {
75
+ const [type, status, issuanceId, recipient] = line.trim().split(/\s+/);
76
+ if (!type) return undefined;
77
+ return {
78
+ type,
79
+ ...(status && { status }),
80
+ ...(issuanceId && { issuanceId }),
81
+ ...(recipient && { recipient }),
82
+ };
83
+ };
84
+
85
+ /**
86
+ * `--json` mode does not stream: intercept the receiver's own console.log lines (never
87
+ * forwarding them to real stdout) and resolve with everything seen once ISSUANCE_DELIVERED
88
+ * (and ISSUANCE_CLAIMED, if requested) has arrived, or once the timeout elapses.
89
+ */
90
+ export const collectWebhookEvents = (
91
+ timeoutMs: number,
92
+ waitForClaim: boolean,
93
+ registerStop?: (stop: () => void) => void
94
+ ): Promise<WebhookEvent[]> =>
95
+ new Promise(resolve => {
96
+ const events: WebhookEvent[] = [];
97
+ const originalLog = console.log;
98
+ const finish = (): void => {
99
+ clearTimeout(timer);
100
+ console.log = originalLog;
101
+ resolve(events);
102
+ };
103
+ const timer = setTimeout(finish, Math.max(0, timeoutMs));
104
+ registerStop?.(finish);
105
+ console.log = (...args: unknown[]): void => {
106
+ const [first] = args;
107
+ const event = typeof first === 'string' ? parseWebhookLogLine(first) : undefined;
108
+ if (!event) return;
109
+ events.push(event);
110
+ const delivered = events.some(e => e.type === 'ISSUANCE_DELIVERED');
111
+ const claimed = events.some(e => e.type === 'ISSUANCE_CLAIMED');
112
+ if (delivered && (!waitForClaim || claimed)) finish();
113
+ };
114
+ });
115
+
116
+ export const runWebhook = async (
117
+ email: string | undefined,
118
+ options: ProjectOptions & {
119
+ to?: string;
120
+ url?: string;
121
+ port?: string;
122
+ timeout?: string;
123
+ waitForClaim?: boolean;
124
+ }
125
+ ): Promise<void> => {
126
+ const project = await loadProject(process.cwd());
127
+ const { port, expectedDid } = webhookConfig(project.env, options);
128
+ const production = resolveServices(project.env, options.network).network === PRODUCTION_NETWORK;
129
+ if (options.url && production && new URL(options.url).protocol !== 'https:') {
130
+ throw new Error('--url must be a public HTTPS URL when using the production network.');
131
+ }
132
+ const identity = await ensureIdentity(project, options);
133
+ const learnCard = await connect(project, { ...options, lca: true });
134
+ await ensureProfile(learnCard, identity, project);
135
+ const wroteWebhookMjs = await writeSnippet(
136
+ 'webhook.mjs',
137
+ localizeSnippet(WEBHOOK_MJS, resolveServices(project.env, options.network))
138
+ );
139
+ const verifier = await initLearnCard({ ...(options.didkit && { didkit: options.didkit }) });
140
+ const { createWebhookReceiver } = await loadWebhookModule();
141
+ const server = createWebhookReceiver(verifier, expectedDid);
142
+ server.requestTimeout = 5000;
143
+ await new Promise<void>((resolve, reject) => {
144
+ server.once('error', reject);
145
+ server.listen(port, () => {
146
+ server.removeListener('error', reject);
147
+ resolve();
148
+ });
149
+ });
150
+ out.log(`Listening on http://localhost:${port}`);
151
+ try {
152
+ if (!options.url) {
153
+ out.log(
154
+ `Next: expose port ${port} with ngrok/cloudflared and re-run with --url <publicUrl>. No credential sent.`
155
+ );
156
+ out.set({ port, files: wroteWebhookMjs ? ['./webhook.mjs'] : [] });
157
+ return;
158
+ }
159
+ if (!expectedDid) {
160
+ out.log(
161
+ 'Demo: signatures are verified, but any DID is accepted. Set EXPECTED_NETWORK_DID to your trusted network DID before production.'
162
+ );
163
+ }
164
+ const prompts = createPrompts(options.yes);
165
+ let recipient: string;
166
+ try {
167
+ recipient =
168
+ email || options.to || (await prompts.ask('Recipient email (--to <email>)', ''));
169
+ } finally {
170
+ prompts.close();
171
+ }
172
+ if (!recipient) throw new Error('Provide an email argument or --to <email>.');
173
+ // Template claims need a hosted signer; the demo does not persist signer configuration.
174
+ await setupSigning(project, learnCard, undefined, { persist: false });
175
+ if (!project.env[KEYS.TEMPLATE_URI]) {
176
+ const uri = await learnCard.invoke.createBoost(templateCredential(learnCard.id.did()), {
177
+ name: DEFAULT_BADGE.name,
178
+ category: 'Achievement',
179
+ status: 'LIVE',
180
+ });
181
+ await saveProject(project, { [KEYS.TEMPLATE_URI]: uri });
182
+ }
183
+ const templateUri = project.env[KEYS.TEMPLATE_URI]!;
184
+ // Start collecting before send() so an ISSUANCE_DELIVERED that arrives mid-call isn't missed.
185
+ const timeoutMs = (Number(options.timeout) > 0 ? Number(options.timeout) : 60) * 1000;
186
+ let stopCollectingEvents: (() => void) | undefined;
187
+ const eventsPromise = out.json
188
+ ? collectWebhookEvents(timeoutMs, !!options.waitForClaim, stop => {
189
+ stopCollectingEvents = stop;
190
+ })
191
+ : undefined;
192
+ const onServerError = (): void => {};
193
+ if (eventsPromise) server.on('error', onServerError);
194
+ const result = await learnCard.invoke
195
+ .send({
196
+ type: 'boost',
197
+ recipient,
198
+ templateUri,
199
+ options: { webhookUrl: options.url },
200
+ })
201
+ .catch(error => {
202
+ if (eventsPromise) {
203
+ server.removeListener('error', onServerError);
204
+ stopCollectingEvents?.();
205
+ }
206
+ throw error;
207
+ });
208
+ out.log(`Sent. Watch for ISSUANCE_DELIVERED ${result.inbox?.status ?? ''}.`);
209
+ out.log(
210
+ result.inbox?.status === 'PENDING'
211
+ ? `Next: click the claim link in your email to see ISSUANCE_CLAIMED. Ctrl+C to stop.`
212
+ : 'Next: this recipient already has LearnCard; no claim event is expected. Ctrl+C to stop.'
213
+ );
214
+ if (eventsPromise) {
215
+ const events = await eventsPromise;
216
+ server.removeListener('error', onServerError);
217
+ out.set({
218
+ port,
219
+ url: options.url,
220
+ templateUri,
221
+ issuanceId: result.inbox?.issuanceId,
222
+ events,
223
+ });
224
+ return;
225
+ }
226
+ await new Promise<void>((resolve, reject) => {
227
+ const stop = (): void => {
228
+ cleanup();
229
+ resolve();
230
+ };
231
+ const fail = (error: Error): void => {
232
+ cleanup();
233
+ reject(error);
234
+ };
235
+ const cleanup = (): void => {
236
+ process.removeListener('SIGINT', stop);
237
+ process.removeListener('SIGTERM', stop);
238
+ server.removeListener('error', fail);
239
+ };
240
+ process.once('SIGINT', stop);
241
+ process.once('SIGTERM', stop);
242
+ server.once('error', fail);
243
+ });
244
+ } finally {
245
+ await closeWebhookReceiver(server);
246
+ }
247
+ };
package/tsconfig.json CHANGED
@@ -34,5 +34,6 @@
34
34
  "skipLibCheck": true,
35
35
  "forceConsistentCasingInFileNames": true
36
36
  },
37
+ "include": ["src/**/*", "scripts/**/*.ts"],
37
38
  "exclude": ["./dist/**/*", "./node_modules/**/*"]
38
39
  }