@learncard/cli 3.6.8 → 4.0.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 +85 -0
- package/README.md +36 -12
- package/dist/index.js +6673 -2038
- package/package.json +27 -15
- package/project.json +0 -42
- package/rollup.config.js +0 -34
- package/scripts/embed-snippets.ts +0 -47
- package/src/auth-grant.test.ts +0 -54
- package/src/auth-grant.ts +0 -34
- package/src/clr/validate.test.ts +0 -65
- package/src/clr/validate.ts +0 -242
- package/src/clr.ts +0 -119
- package/src/consent-contract.ts +0 -116
- package/src/demo-inbox-refresh.test.ts +0 -737
- package/src/demo-inbox-refresh.ts +0 -804
- package/src/demo-refresh-command.test.ts +0 -57
- package/src/demo-refresh-command.ts +0 -22
- package/src/demo-refresh-ui.test.ts +0 -66
- package/src/demo-refresh-ui.ts +0 -65
- package/src/demo-refresh.test.ts +0 -140
- package/src/demo-refresh.ts +0 -309
- package/src/doctor/checks.test.ts +0 -448
- package/src/doctor/checks.ts +0 -497
- package/src/doctor.test.ts +0 -67
- package/src/doctor.ts +0 -118
- package/src/embed.ts +0 -102
- package/src/inbox.test.ts +0 -257
- package/src/inbox.ts +0 -221
- package/src/index.tsx +0 -876
- package/src/init.ts +0 -26
- package/src/open.test.ts +0 -40
- package/src/open.ts +0 -186
- package/src/org/apply.test.ts +0 -1108
- package/src/org/apply.ts +0 -924
- package/src/org/branding.test.ts +0 -60
- package/src/org/diff.ts +0 -14
- package/src/org/load.ts +0 -50
- package/src/org/schema.test.ts +0 -256
- package/src/org/schema.ts +0 -216
- package/src/org.ts +0 -124
- package/src/out.test.ts +0 -40
- package/src/out.ts +0 -16
- package/src/phase-two.test.ts +0 -154
- package/src/project.test.ts +0 -232
- package/src/project.ts +0 -489
- package/src/promote.test.ts +0 -142
- package/src/promote.ts +0 -202
- package/src/random.ts +0 -3
- package/src/refresh.test.ts +0 -86
- package/src/refresh.ts +0 -93
- package/src/replHelpers.test.ts +0 -80
- package/src/replHelpers.ts +0 -45
- package/src/revoke.test.ts +0 -29
- package/src/revoke.ts +0 -65
- package/src/send-template.test.ts +0 -20
- package/src/send.test.ts +0 -321
- package/src/send.ts +0 -373
- package/src/setup-signing.test.ts +0 -62
- package/src/setup-signing.ts +0 -185
- package/src/snippet-files.ts +0 -16
- package/src/status.test.ts +0 -33
- package/src/status.ts +0 -94
- package/src/token.test.ts +0 -28
- package/src/token.ts +0 -139
- package/src/types.d.ts +0 -17
- package/src/verify.test.ts +0 -35
- package/src/verify.ts +0 -66
- package/src/webhook.ts +0 -247
- package/src/whoami.test.ts +0 -67
- package/src/whoami.ts +0 -129
- package/tsconfig.json +0 -38
- package/vitest.config.ts +0 -8
package/src/send.test.ts
DELETED
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
|
-
import fs from 'fs/promises';
|
|
4
|
-
import * as project from './project';
|
|
5
|
-
import { out } from './out';
|
|
6
|
-
|
|
7
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
SEND_MJS,
|
|
11
|
-
parseEnv,
|
|
12
|
-
upsertEnv,
|
|
13
|
-
toProfileId,
|
|
14
|
-
isPlaceholderRecipient,
|
|
15
|
-
invalidRecipientReason,
|
|
16
|
-
resolveRecipient,
|
|
17
|
-
RECIPIENT_PROMPT,
|
|
18
|
-
personalizeSendMjs,
|
|
19
|
-
withManagedIssuer,
|
|
20
|
-
runSend,
|
|
21
|
-
} from './send';
|
|
22
|
-
|
|
23
|
-
const fakePrompts = (answers: string[], interactive = true) => {
|
|
24
|
-
const asked: string[] = [];
|
|
25
|
-
return {
|
|
26
|
-
asked,
|
|
27
|
-
prompts: {
|
|
28
|
-
interactive,
|
|
29
|
-
ask: async (question: string, fallback: string) => {
|
|
30
|
-
asked.push(question);
|
|
31
|
-
return answers.shift() ?? fallback;
|
|
32
|
-
},
|
|
33
|
-
close: () => {},
|
|
34
|
-
},
|
|
35
|
-
};
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
describe('send command', () => {
|
|
39
|
-
it('ships the exact send.mjs the Quickstart docs show', () => {
|
|
40
|
-
const docsSnippet = readFileSync(
|
|
41
|
-
resolve(__dirname, '../../../docs/snippets/quickstart/send.mjs'),
|
|
42
|
-
'utf8'
|
|
43
|
-
);
|
|
44
|
-
expect(SEND_MJS).toBe(docsSnippet);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('parses and upserts .env without touching unrelated lines', () => {
|
|
48
|
-
const original = '# comment\nOTHER=1\nSECURE_SEED=old\n';
|
|
49
|
-
expect(parseEnv(original)).toEqual({ OTHER: '1', SECURE_SEED: 'old' });
|
|
50
|
-
expect(upsertEnv(original, { SECURE_SEED: 'new', PROFILE_ID: 'acme' })).toBe(
|
|
51
|
-
'# comment\nOTHER=1\nSECURE_SEED=new\nPROFILE_ID=acme\n'
|
|
52
|
-
);
|
|
53
|
-
expect(upsertEnv('', { SECURE_SEED: 'x' })).toBe('SECURE_SEED=x\n');
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('derives a URL-safe profile id with a random suffix', () => {
|
|
57
|
-
expect(toProfileId('Acme Learning, Inc.')).toMatch(/^acme-learning-inc-[a-z0-9]{4}$/);
|
|
58
|
-
expect(toProfileId('!!!')).toMatch(/^issuer-[a-z0-9]{4}$/);
|
|
59
|
-
expect(toProfileId('x'.repeat(60)).length).toBeLessThanOrEqual(40);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('recipient validation', () => {
|
|
64
|
-
it('flags RFC 2606 / 6761 placeholder domains but not real ones', () => {
|
|
65
|
-
expect(isPlaceholderRecipient('you@example.com')).toBe(true);
|
|
66
|
-
expect(isPlaceholderRecipient('YOU@EXAMPLE.ORG')).toBe(true);
|
|
67
|
-
expect(isPlaceholderRecipient('a@sub.example.net')).toBe(true);
|
|
68
|
-
expect(isPlaceholderRecipient('a@foo.test')).toBe(true);
|
|
69
|
-
expect(isPlaceholderRecipient('a@host.invalid')).toBe(true);
|
|
70
|
-
expect(isPlaceholderRecipient('a@localhost')).toBe(true);
|
|
71
|
-
expect(isPlaceholderRecipient('a@test')).toBe(true);
|
|
72
|
-
expect(isPlaceholderRecipient('a@contest.com')).toBe(false);
|
|
73
|
-
expect(isPlaceholderRecipient('a@latest.io')).toBe(false);
|
|
74
|
-
expect(isPlaceholderRecipient('a@test.com')).toBe(false);
|
|
75
|
-
expect(isPlaceholderRecipient('a@myexample.com')).toBe(false);
|
|
76
|
-
expect(isPlaceholderRecipient('a@example.co.uk')).toBe(false);
|
|
77
|
-
expect(isPlaceholderRecipient('+15555550100')).toBe(false);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('explains why a recipient is unusable', () => {
|
|
81
|
-
expect(invalidRecipientReason('you@example.com')).toMatch(/placeholder/);
|
|
82
|
-
expect(invalidRecipientReason('Not A Recipient!')).toMatch(/not an email/);
|
|
83
|
-
expect(invalidRecipientReason('')).toBe(
|
|
84
|
-
'Enter an email, phone number, profile ID, or DID.'
|
|
85
|
-
);
|
|
86
|
-
expect(invalidRecipientReason('me@acme.org')).toBeUndefined();
|
|
87
|
-
expect(invalidRecipientReason('+15555550100')).toBeUndefined();
|
|
88
|
-
expect(invalidRecipientReason('cs-exampleville')).toBeUndefined();
|
|
89
|
-
expect(invalidRecipientReason('did:web:network.learncard.com:users:exde')).toBeUndefined();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('accepts a valid recipient without prompting', async () => {
|
|
93
|
-
const { prompts, asked } = fakePrompts([]);
|
|
94
|
-
expect(await resolveRecipient(' me@acme.org ', prompts)).toBe('me@acme.org');
|
|
95
|
-
expect(asked).toEqual([]);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('prompts when the recipient is omitted', async () => {
|
|
99
|
-
const { prompts, asked } = fakePrompts(['me@acme.org']);
|
|
100
|
-
expect(await resolveRecipient(undefined, prompts)).toBe('me@acme.org');
|
|
101
|
-
expect(asked).toEqual([RECIPIENT_PROMPT]);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('re-prompts interactively until a placeholder is replaced', async () => {
|
|
105
|
-
const { prompts, asked } = fakePrompts(['not a recipient', 'me@acme.org']);
|
|
106
|
-
expect(await resolveRecipient('you@example.com', prompts)).toBe('me@acme.org');
|
|
107
|
-
expect(asked).toEqual([RECIPIENT_PROMPT, RECIPIENT_PROMPT]);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('re-prompts with a hint when Enter is pressed on an empty line', async () => {
|
|
111
|
-
const { prompts, asked } = fakePrompts(['', ' ', 'me@acme.org']);
|
|
112
|
-
expect(await resolveRecipient(undefined, prompts)).toBe('me@acme.org');
|
|
113
|
-
expect(asked).toHaveLength(3);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('fails fast on a missing recipient when non-interactive', async () => {
|
|
117
|
-
const { prompts, asked } = fakePrompts(['me@acme.org'], false);
|
|
118
|
-
await expect(resolveRecipient(undefined, prompts)).rejects.toThrow(/recipient is required/);
|
|
119
|
-
expect(asked).toEqual([]);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('fails fast on a placeholder when non-interactive', async () => {
|
|
123
|
-
const { prompts, asked } = fakePrompts(['me@acme.org'], false);
|
|
124
|
-
await expect(resolveRecipient('you@example.com', prompts)).rejects.toThrow(
|
|
125
|
-
/placeholder address/
|
|
126
|
-
);
|
|
127
|
-
expect(asked).toEqual([]);
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
describe('personalizeSendMjs', () => {
|
|
132
|
-
const managedDid = 'did:web:network.learncard.com:users:managed';
|
|
133
|
-
const script = withManagedIssuer(
|
|
134
|
-
personalizeSendMjs('Parent', {
|
|
135
|
-
name: 'Managed badge',
|
|
136
|
-
description: 'Sent as managed.',
|
|
137
|
-
}),
|
|
138
|
-
managedDid
|
|
139
|
-
);
|
|
140
|
-
const execute = (env: Record<string, string>, initLearnCard: ReturnType<typeof vi.fn>) => {
|
|
141
|
-
const body = script
|
|
142
|
-
.split('\n')
|
|
143
|
-
.filter(line => !line.startsWith('import '))
|
|
144
|
-
.join('\n');
|
|
145
|
-
return new Function(
|
|
146
|
-
'initLearnCard',
|
|
147
|
-
'process',
|
|
148
|
-
'randomUUID',
|
|
149
|
-
'console',
|
|
150
|
-
`return (async () => {${body}})();`
|
|
151
|
-
)(initLearnCard, { env, argv: ['node', 'send.mjs', 'me@acme.org'] }, () => 'uuid', {
|
|
152
|
-
log: vi.fn(),
|
|
153
|
-
});
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
it('re-runs the managed script with didWeb and issues as that profile', async () => {
|
|
157
|
-
const card = {
|
|
158
|
-
id: { did: () => managedDid },
|
|
159
|
-
invoke: {
|
|
160
|
-
getProfile: vi.fn().mockResolvedValue({ profileId: 'managed' }),
|
|
161
|
-
issueCredential: vi.fn().mockResolvedValue({ proof: {} }),
|
|
162
|
-
send: vi.fn().mockResolvedValue({ uri: 'boost-uri' }),
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
const initLearnCard = vi.fn().mockResolvedValue(card);
|
|
166
|
-
await execute({ SECURE_SEED: 'parent-seed', MANAGED_DID: managedDid }, initLearnCard);
|
|
167
|
-
expect(initLearnCard).toHaveBeenCalledWith({
|
|
168
|
-
seed: 'parent-seed',
|
|
169
|
-
network: true,
|
|
170
|
-
didWeb: managedDid,
|
|
171
|
-
});
|
|
172
|
-
expect(card.invoke.issueCredential).toHaveBeenCalledWith(
|
|
173
|
-
expect.objectContaining({ issuer: managedDid })
|
|
174
|
-
);
|
|
175
|
-
expect(script).not.toContain('createProfile');
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it.each([undefined, 'did:web:other:users:another'])(
|
|
179
|
-
'fails closed for MANAGED_DID=%s',
|
|
180
|
-
async did => {
|
|
181
|
-
const initLearnCard = vi.fn();
|
|
182
|
-
await expect(execute(did ? { MANAGED_DID: did } : {}, initLearnCard)).rejects.toThrow(
|
|
183
|
-
'MANAGED_DID is missing or changed'
|
|
184
|
-
);
|
|
185
|
-
expect(initLearnCard).not.toHaveBeenCalled();
|
|
186
|
-
}
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
it('does not create a parent profile when the managed profile is missing', async () => {
|
|
190
|
-
const initLearnCard = vi
|
|
191
|
-
.fn()
|
|
192
|
-
.mockResolvedValue({ invoke: { getProfile: vi.fn().mockResolvedValue(null) } });
|
|
193
|
-
await expect(execute({ MANAGED_DID: managedDid }, initLearnCard)).rejects.toThrow(
|
|
194
|
-
'managed issuer profile could not be found'
|
|
195
|
-
);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it('substitutes the display name, badge name, and description', async () => {
|
|
199
|
-
const { personalizeSendMjs } = await import('./send');
|
|
200
|
-
const out = personalizeSendMjs('Acme Learning', {
|
|
201
|
-
name: 'Welcome to Acme',
|
|
202
|
-
description: 'You joined.',
|
|
203
|
-
});
|
|
204
|
-
expect(out).toContain('displayName: "Acme Learning"');
|
|
205
|
-
expect(out).toContain('name: "Welcome to Acme"');
|
|
206
|
-
expect(out).not.toContain('Quickstart Complete');
|
|
207
|
-
expect(out).toContain('description: "You joined."');
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
describe('classifyRecipient', () => {
|
|
212
|
-
it('detects each recipient kind the network routes on', async () => {
|
|
213
|
-
const { classifyRecipient } = await import('./send');
|
|
214
|
-
expect(classifyRecipient('you@example.com')).toBe('email');
|
|
215
|
-
expect(classifyRecipient('+15555550123')).toBe('phone');
|
|
216
|
-
expect(classifyRecipient('did:web:network.learncard.com:users:exde')).toBe('did');
|
|
217
|
-
expect(classifyRecipient('cs-exampleville')).toBe('profileId');
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it('rejects anything else with an example', async () => {
|
|
221
|
-
const { classifyRecipient } = await import('./send');
|
|
222
|
-
expect(() => classifyRecipient('Not A Recipient!')).toThrow(/profile ID, or DID/);
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
describe('send --as script generation', () => {
|
|
227
|
-
afterEach(() => vi.restoreAllMocks());
|
|
228
|
-
|
|
229
|
-
it.each([false, true])(
|
|
230
|
-
'preserves the managed issuer with existing script=%s',
|
|
231
|
-
async existing => {
|
|
232
|
-
const managedDid = 'did:web:staging.network.learncard.com:users:managed';
|
|
233
|
-
const loaded = {
|
|
234
|
-
env: { SECURE_SEED: 'seed', PROFILE_ID: 'parent' },
|
|
235
|
-
envPath: '/unused/.env',
|
|
236
|
-
existing: '',
|
|
237
|
-
};
|
|
238
|
-
vi.spyOn(project, 'loadProject').mockResolvedValue(loaded);
|
|
239
|
-
vi.spyOn(project, 'createPrompts').mockReturnValue(fakePrompts([], false).prompts);
|
|
240
|
-
vi.spyOn(project, 'ensureIdentity').mockResolvedValue({
|
|
241
|
-
seed: 'seed',
|
|
242
|
-
profileId: 'parent',
|
|
243
|
-
displayName: 'Parent',
|
|
244
|
-
});
|
|
245
|
-
const card = {
|
|
246
|
-
id: { did: () => managedDid },
|
|
247
|
-
invoke: {
|
|
248
|
-
getProfile: vi
|
|
249
|
-
.fn()
|
|
250
|
-
.mockResolvedValue({ profileId: 'managed', displayName: 'Managed' }),
|
|
251
|
-
issueCredential: vi.fn().mockResolvedValue({}),
|
|
252
|
-
send: vi.fn().mockResolvedValue({ uri: 'boost-uri', activityId: 'activity-1' }),
|
|
253
|
-
},
|
|
254
|
-
};
|
|
255
|
-
vi.spyOn(project, 'connectAsManaged').mockResolvedValue(
|
|
256
|
-
card as unknown as Awaited<ReturnType<typeof project.connectAsManaged>>
|
|
257
|
-
);
|
|
258
|
-
const save = vi.spyOn(project, 'saveProject').mockResolvedValue();
|
|
259
|
-
const stat = vi.spyOn(fs, 'stat');
|
|
260
|
-
if (existing) stat.mockResolvedValue({} as Awaited<ReturnType<typeof fs.stat>>);
|
|
261
|
-
else stat.mockRejectedValue(new Error('ENOENT'));
|
|
262
|
-
const write = vi.spyOn(fs, 'writeFile').mockResolvedValue();
|
|
263
|
-
const log = vi.spyOn(out, 'log').mockImplementation(() => {});
|
|
264
|
-
const set = vi.spyOn(out, 'set').mockImplementation(patch => patch);
|
|
265
|
-
|
|
266
|
-
await runSend('me@acme.org', { as: 'managed', yes: true, network: 'staging' });
|
|
267
|
-
|
|
268
|
-
if (existing) {
|
|
269
|
-
expect(save).not.toHaveBeenCalled();
|
|
270
|
-
expect(write).not.toHaveBeenCalled();
|
|
271
|
-
expect(log).toHaveBeenCalledWith(
|
|
272
|
-
expect.stringContaining('may use a different issuer')
|
|
273
|
-
);
|
|
274
|
-
expect(set).toHaveBeenCalledWith(expect.objectContaining({ files: [] }));
|
|
275
|
-
} else {
|
|
276
|
-
expect(save).toHaveBeenCalledWith(loaded, { MANAGED_DID: managedDid });
|
|
277
|
-
expect(write).toHaveBeenCalledWith(
|
|
278
|
-
expect.stringContaining('send.mjs'),
|
|
279
|
-
expect.stringContaining('didWeb: process.env.MANAGED_DID')
|
|
280
|
-
);
|
|
281
|
-
expect(save.mock.invocationCallOrder[0]).toBeLessThan(
|
|
282
|
-
write.mock.invocationCallOrder[0]!
|
|
283
|
-
);
|
|
284
|
-
expect(set).toHaveBeenCalledWith(
|
|
285
|
-
expect.objectContaining({ files: ['./send.mjs'] })
|
|
286
|
-
);
|
|
287
|
-
const generated = String(write.mock.calls[0]?.[1]);
|
|
288
|
-
const body = generated
|
|
289
|
-
.split('\n')
|
|
290
|
-
.filter(line => !line.startsWith('import '))
|
|
291
|
-
.join('\n');
|
|
292
|
-
const initLearnCard = vi.fn().mockResolvedValue(card);
|
|
293
|
-
card.invoke.issueCredential.mockClear();
|
|
294
|
-
await new Function(
|
|
295
|
-
'initLearnCard',
|
|
296
|
-
'process',
|
|
297
|
-
'randomUUID',
|
|
298
|
-
'console',
|
|
299
|
-
`return (async () => {${body}})();`
|
|
300
|
-
)(
|
|
301
|
-
initLearnCard,
|
|
302
|
-
{
|
|
303
|
-
env: { SECURE_SEED: 'seed', MANAGED_DID: managedDid },
|
|
304
|
-
argv: ['node', 'send.mjs', 'me@acme.org'],
|
|
305
|
-
},
|
|
306
|
-
() => 'uuid',
|
|
307
|
-
{ log: vi.fn() }
|
|
308
|
-
);
|
|
309
|
-
expect(initLearnCard).toHaveBeenCalledWith({
|
|
310
|
-
seed: 'seed',
|
|
311
|
-
network: project.STAGING_NETWORK,
|
|
312
|
-
cloud: { url: 'https://staging.cloud.learncard.com/trpc' },
|
|
313
|
-
didWeb: managedDid,
|
|
314
|
-
});
|
|
315
|
-
expect(card.invoke.issueCredential).toHaveBeenCalledWith(
|
|
316
|
-
expect.objectContaining({ issuer: managedDid })
|
|
317
|
-
);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
);
|
|
321
|
-
});
|
package/src/send.ts
DELETED
|
@@ -1,373 +0,0 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import {
|
|
5
|
-
connect,
|
|
6
|
-
connectAsManaged,
|
|
7
|
-
createPrompts,
|
|
8
|
-
ensureIdentity,
|
|
9
|
-
ensureProfile,
|
|
10
|
-
loadProject,
|
|
11
|
-
saveProject,
|
|
12
|
-
type ProjectOptions,
|
|
13
|
-
localizeSnippet,
|
|
14
|
-
resolveServices,
|
|
15
|
-
} from './project';
|
|
16
|
-
import { setupSigning } from './setup-signing';
|
|
17
|
-
import { SEND_MJS, SEND_FROM_TEMPLATE_MJS } from './generated/snippets';
|
|
18
|
-
import { out } from './out';
|
|
19
|
-
|
|
20
|
-
export { SEND_MJS } from './generated/snippets';
|
|
21
|
-
export { parseEnv, upsertEnv, toProfileId } from './project';
|
|
22
|
-
export type SendEnv = { SECURE_SEED?: string; PROFILE_ID?: string };
|
|
23
|
-
export type Badge = { name: string; description: string };
|
|
24
|
-
export const DEFAULT_BADGE: Badge = {
|
|
25
|
-
name: 'Quickstart Complete',
|
|
26
|
-
description: 'Sent a verifiable credential with LearnCard.',
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
export const quickstartCredential = (issuerDid: string, badge: Badge = DEFAULT_BADGE) => ({
|
|
30
|
-
'@context': [
|
|
31
|
-
'https://www.w3.org/ns/credentials/v2',
|
|
32
|
-
'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json',
|
|
33
|
-
],
|
|
34
|
-
type: ['VerifiableCredential', 'OpenBadgeCredential'],
|
|
35
|
-
issuer: issuerDid,
|
|
36
|
-
validFrom: new Date().toISOString(),
|
|
37
|
-
name: badge.name,
|
|
38
|
-
credentialSubject: {
|
|
39
|
-
type: ['AchievementSubject'],
|
|
40
|
-
achievement: {
|
|
41
|
-
id: `urn:uuid:${randomUUID()}`,
|
|
42
|
-
type: ['Achievement'],
|
|
43
|
-
name: badge.name,
|
|
44
|
-
description: badge.description,
|
|
45
|
-
criteria: { narrative: 'Ran the LearnCard quickstart.' },
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
export const templateCredential = (issuerDid: string, badge: Badge = DEFAULT_BADGE) => {
|
|
51
|
-
const credential = quickstartCredential(issuerDid, badge);
|
|
52
|
-
credential['@context'].push('https://ctx.learncard.com/boosts/1.0.1.json');
|
|
53
|
-
credential.type.push('BoostCredential');
|
|
54
|
-
return credential;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
type SendOptions = ProjectOptions & {
|
|
58
|
-
as?: string;
|
|
59
|
-
badge?: string;
|
|
60
|
-
description?: string;
|
|
61
|
-
template?: boolean;
|
|
62
|
-
templateUri?: string;
|
|
63
|
-
webhookUrl?: string;
|
|
64
|
-
suppressDelivery?: boolean;
|
|
65
|
-
guardianEmail?: string;
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
type SendDeliveryOptions = {
|
|
69
|
-
webhookUrl?: string;
|
|
70
|
-
suppressDelivery?: boolean;
|
|
71
|
-
guardianEmail?: string;
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const sendOptions = (options: SendOptions): { options?: SendDeliveryOptions } => {
|
|
75
|
-
const picked: SendDeliveryOptions = {
|
|
76
|
-
webhookUrl: options.webhookUrl,
|
|
77
|
-
suppressDelivery: options.suppressDelivery,
|
|
78
|
-
guardianEmail: options.guardianEmail,
|
|
79
|
-
};
|
|
80
|
-
return Object.values(picked).some(v => v !== undefined) ? { options: picked } : {};
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
/** Inserts the effective send() delivery options right after `anchor` so a re-run of the
|
|
84
|
-
* generated script reproduces the same call. */
|
|
85
|
-
const withDeliveryOptions = (
|
|
86
|
-
content: string,
|
|
87
|
-
anchor: string,
|
|
88
|
-
deliveryOptions?: SendDeliveryOptions
|
|
89
|
-
): string =>
|
|
90
|
-
deliveryOptions
|
|
91
|
-
? content.replace(
|
|
92
|
-
anchor,
|
|
93
|
-
() => `${anchor}\n options: ${JSON.stringify(deliveryOptions)},`
|
|
94
|
-
)
|
|
95
|
-
: content;
|
|
96
|
-
|
|
97
|
-
/** Substitute the user's choices into the template that the docs snippet uses. */
|
|
98
|
-
export const personalizeSendMjs = (
|
|
99
|
-
displayName: string,
|
|
100
|
-
badge: Badge,
|
|
101
|
-
deliveryOptions?: SendDeliveryOptions
|
|
102
|
-
): string =>
|
|
103
|
-
withDeliveryOptions(
|
|
104
|
-
SEND_MJS.replace(
|
|
105
|
-
"displayName: 'My Organization'",
|
|
106
|
-
() => `displayName: ${JSON.stringify(displayName)}`
|
|
107
|
-
)
|
|
108
|
-
.split("'Quickstart Complete'")
|
|
109
|
-
.join(JSON.stringify(badge.name))
|
|
110
|
-
.replace("'Sent a verifiable credential with LearnCard.'", () =>
|
|
111
|
-
JSON.stringify(badge.description)
|
|
112
|
-
),
|
|
113
|
-
' signedCredential: credential,',
|
|
114
|
-
deliveryOptions
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
/** Substitute the effective send() delivery options into the from-template script. */
|
|
118
|
-
export const personalizeSendFromTemplateMjs = (deliveryOptions?: SendDeliveryOptions): string =>
|
|
119
|
-
withDeliveryOptions(
|
|
120
|
-
SEND_FROM_TEMPLATE_MJS,
|
|
121
|
-
' templateUri: process.env.TEMPLATE_URI,',
|
|
122
|
-
deliveryOptions
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
/** Bind a generated script to its managed issuer, never the parent seed identity. */
|
|
126
|
-
export const withManagedIssuer = (content: string, managedDid: string): string =>
|
|
127
|
-
content
|
|
128
|
-
.replace(
|
|
129
|
-
'const learnCard = await initLearnCard(',
|
|
130
|
-
`if (process.env.MANAGED_DID !== ${JSON.stringify(managedDid)}) {
|
|
131
|
-
throw new Error('MANAGED_DID is missing or changed. Re-run the CLI with --as to send as the intended profile.');
|
|
132
|
-
}
|
|
133
|
-
const learnCard = await initLearnCard(`
|
|
134
|
-
)
|
|
135
|
-
.replace(
|
|
136
|
-
'seed: process.env.SECURE_SEED,',
|
|
137
|
-
'seed: process.env.SECURE_SEED, didWeb: process.env.MANAGED_DID,'
|
|
138
|
-
)
|
|
139
|
-
.replace(
|
|
140
|
-
/if \(!\(await learnCard\.invoke\.getProfile\(\)\)\) \{[\s\S]*?\n\}/,
|
|
141
|
-
`if (!(await learnCard.invoke.getProfile())) {
|
|
142
|
-
throw new Error('The managed issuer profile could not be found.');
|
|
143
|
-
}`
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
147
|
-
const PHONE = /^\+?\d{10,15}$/;
|
|
148
|
-
const DID = /^did:[a-z0-9]+:.+$/;
|
|
149
|
-
const PROFILE_ID = /^[a-z0-9-]{3,40}$/;
|
|
150
|
-
|
|
151
|
-
export type RecipientKind = 'email' | 'phone' | 'did' | 'profileId';
|
|
152
|
-
|
|
153
|
-
export const classifyRecipient = (value: string): RecipientKind => {
|
|
154
|
-
if (EMAIL.test(value)) return 'email';
|
|
155
|
-
if (PHONE.test(value)) return 'phone';
|
|
156
|
-
if (DID.test(value)) return 'did';
|
|
157
|
-
if (PROFILE_ID.test(value)) return 'profileId';
|
|
158
|
-
throw new Error(
|
|
159
|
-
`"${value}" is not an email, phone number, profile ID, or DID. Example: npx @learncard/cli send you@yourdomain.com`
|
|
160
|
-
);
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
/** Domains reserved for documentation (RFC 2606 / RFC 6761). Mail to them goes nowhere. */
|
|
164
|
-
const PLACEHOLDER_DOMAIN = /(^|\.)example\.(com|net|org)$|(^|\.)(example|test|invalid|localhost)$/i;
|
|
165
|
-
|
|
166
|
-
export const RECIPIENT_PROMPT =
|
|
167
|
-
'Where should we send your first badge? (email, phone number, profile ID, or DID)';
|
|
168
|
-
|
|
169
|
-
export const isPlaceholderRecipient = (recipient: string): boolean => {
|
|
170
|
-
const at = recipient.lastIndexOf('@');
|
|
171
|
-
return at !== -1 && PLACEHOLDER_DOMAIN.test(recipient.slice(at + 1));
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Returns a human-readable reason `recipient` cannot receive a badge, or `undefined` if it can.
|
|
176
|
-
* Placeholder addresses like you@example.com pass a naive email check but are undeliverable,
|
|
177
|
-
* so they are called out specifically — docs readers paste them verbatim.
|
|
178
|
-
*/
|
|
179
|
-
export const invalidRecipientReason = (recipient: string): string | undefined => {
|
|
180
|
-
if (!recipient) return 'Enter an email, phone number, profile ID, or DID.';
|
|
181
|
-
if (EMAIL.test(recipient) && isPlaceholderRecipient(recipient))
|
|
182
|
-
return `"${recipient}" is a placeholder address — nobody will receive the badge. Use a real email you can open.`;
|
|
183
|
-
if (
|
|
184
|
-
!EMAIL.test(recipient) &&
|
|
185
|
-
!PHONE.test(recipient) &&
|
|
186
|
-
!DID.test(recipient) &&
|
|
187
|
-
!PROFILE_ID.test(recipient)
|
|
188
|
-
)
|
|
189
|
-
return `"${recipient}" is not an email, phone number, profile ID, or DID.`;
|
|
190
|
-
return undefined;
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
type Prompts = ReturnType<typeof createPrompts>;
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Settle on a deliverable recipient. When a human is at the terminal, bad or missing input
|
|
197
|
-
* re-prompts instead of failing; non-interactive runs throw a clear error.
|
|
198
|
-
*/
|
|
199
|
-
export const resolveRecipient = async (
|
|
200
|
-
recipient: string | undefined,
|
|
201
|
-
prompts: Prompts
|
|
202
|
-
): Promise<string> => {
|
|
203
|
-
let candidate = recipient?.trim() ?? '';
|
|
204
|
-
if (!candidate && !prompts.interactive)
|
|
205
|
-
throw new Error(
|
|
206
|
-
'A recipient is required when running non-interactively. Example: npx @learncard/cli send you@yourdomain.com --yes'
|
|
207
|
-
);
|
|
208
|
-
if (!candidate) candidate = (await prompts.ask(RECIPIENT_PROMPT, '')).trim();
|
|
209
|
-
let reason = invalidRecipientReason(candidate);
|
|
210
|
-
while (reason) {
|
|
211
|
-
if (!prompts.interactive)
|
|
212
|
-
throw new Error(`${reason} Example: npx @learncard/cli send you@yourdomain.com`);
|
|
213
|
-
out.log(reason);
|
|
214
|
-
candidate = (await prompts.ask(RECIPIENT_PROMPT, '')).trim();
|
|
215
|
-
reason = invalidRecipientReason(candidate);
|
|
216
|
-
}
|
|
217
|
-
return candidate;
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
export const runSend = async (
|
|
221
|
-
recipient: string | undefined,
|
|
222
|
-
options: SendOptions
|
|
223
|
-
): Promise<void> => {
|
|
224
|
-
const cwd = process.cwd();
|
|
225
|
-
const project = await loadProject(cwd);
|
|
226
|
-
const prompts = createPrompts(options.yes);
|
|
227
|
-
let resolvedRecipient: string;
|
|
228
|
-
let displayName: string | undefined;
|
|
229
|
-
let badge: Badge;
|
|
230
|
-
try {
|
|
231
|
-
resolvedRecipient = await resolveRecipient(recipient, prompts);
|
|
232
|
-
const needsName = !project.env.PROFILE_ID && !options.profileId && !options.name;
|
|
233
|
-
displayName = needsName
|
|
234
|
-
? await prompts.ask('Display name for your issuer profile', 'My Organization')
|
|
235
|
-
: options.name;
|
|
236
|
-
badge = {
|
|
237
|
-
name: options.badge ?? (await prompts.ask('Badge name', DEFAULT_BADGE.name)),
|
|
238
|
-
description: options.description ?? DEFAULT_BADGE.description,
|
|
239
|
-
};
|
|
240
|
-
} finally {
|
|
241
|
-
prompts.close();
|
|
242
|
-
}
|
|
243
|
-
const recipientKind = classifyRecipient(resolvedRecipient);
|
|
244
|
-
const identity = await ensureIdentity(project, { ...options, name: displayName, yes: true });
|
|
245
|
-
const asManaged = options.as ? await connectAsManaged(project, options, options.as) : undefined;
|
|
246
|
-
const hostedSigning = !!project.env.SIGNING_AUTHORITY_NAME;
|
|
247
|
-
const explicitTemplate = options.templateUri ? true : options.template;
|
|
248
|
-
const useTemplate = !asManaged && (explicitTemplate ?? hostedSigning);
|
|
249
|
-
if (useTemplate && explicitTemplate === undefined) {
|
|
250
|
-
out.log(
|
|
251
|
-
`Signing through the registered signing authority "${project.env.SIGNING_AUTHORITY_NAME}" (pass --no-template to sign with the local key instead).`
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
if (asManaged && explicitTemplate) {
|
|
255
|
-
out.log("--as signs with the managed profile's key; --template is ignored.");
|
|
256
|
-
}
|
|
257
|
-
const learnCard = asManaged
|
|
258
|
-
? asManaged
|
|
259
|
-
: useTemplate
|
|
260
|
-
? await connect(project, { ...options, lca: true })
|
|
261
|
-
: await connect(project, options);
|
|
262
|
-
if (asManaged) {
|
|
263
|
-
const managed = await learnCard.invoke.getProfile();
|
|
264
|
-
if (!managed) throw new Error(`Could not sign in as managed profile "${options.as}".`);
|
|
265
|
-
out.log(
|
|
266
|
-
`Acting as "${managed.displayName}" (${managed.profileId}) — a profile you manage.`
|
|
267
|
-
);
|
|
268
|
-
} else {
|
|
269
|
-
await ensureProfile(learnCard, identity, project);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const effectiveSendOptions = sendOptions(options);
|
|
273
|
-
let result;
|
|
274
|
-
if (useTemplate) {
|
|
275
|
-
// This branch connected with the LCA plugin; setupSigning accepts its required methods.
|
|
276
|
-
await setupSigning(
|
|
277
|
-
project,
|
|
278
|
-
learnCard as Awaited<
|
|
279
|
-
ReturnType<typeof import('@learncard/lca-api-plugin').initLCALearnCard>
|
|
280
|
-
>
|
|
281
|
-
);
|
|
282
|
-
if (options.templateUri) {
|
|
283
|
-
out.log(`Sending from template ${options.templateUri}.`);
|
|
284
|
-
if (!project.env.TEMPLATE_URI) {
|
|
285
|
-
await saveProject(project, { TEMPLATE_URI: options.templateUri });
|
|
286
|
-
}
|
|
287
|
-
} else if (!project.env.TEMPLATE_URI) {
|
|
288
|
-
const uri = await learnCard.invoke.createBoost(
|
|
289
|
-
templateCredential(learnCard.id.did(), badge),
|
|
290
|
-
{
|
|
291
|
-
name: badge.name,
|
|
292
|
-
category: 'Achievement',
|
|
293
|
-
status: 'LIVE',
|
|
294
|
-
}
|
|
295
|
-
);
|
|
296
|
-
await saveProject(project, { TEMPLATE_URI: uri });
|
|
297
|
-
} else {
|
|
298
|
-
out.log(
|
|
299
|
-
`Reusing template ${project.env.TEMPLATE_URI}; its saved badge name and description are unchanged.`
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
result = await learnCard.invoke.send({
|
|
303
|
-
type: 'boost',
|
|
304
|
-
recipient: resolvedRecipient,
|
|
305
|
-
templateUri: options.templateUri ?? project.env.TEMPLATE_URI!,
|
|
306
|
-
...effectiveSendOptions,
|
|
307
|
-
});
|
|
308
|
-
} else {
|
|
309
|
-
const credential = await learnCard.invoke.issueCredential(
|
|
310
|
-
quickstartCredential(learnCard.id.did(), badge)
|
|
311
|
-
);
|
|
312
|
-
result = await learnCard.invoke.send({
|
|
313
|
-
type: 'boost',
|
|
314
|
-
recipient: resolvedRecipient,
|
|
315
|
-
signedCredential: credential,
|
|
316
|
-
...effectiveSendOptions,
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
out.log('');
|
|
320
|
-
if (result.inbox?.status === 'PENDING') {
|
|
321
|
-
out.log(
|
|
322
|
-
`Sent. ${resolvedRecipient} will get a claim ${recipientKind === 'phone' ? 'text' : 'email'}. You can also share this link directly:\n${result.inbox.claimUrl}`
|
|
323
|
-
);
|
|
324
|
-
} else if (recipientKind === 'email' || recipientKind === 'phone') {
|
|
325
|
-
out.log(
|
|
326
|
-
`Delivered. ${resolvedRecipient} already uses LearnCard — the credential is in their wallet.`
|
|
327
|
-
);
|
|
328
|
-
} else {
|
|
329
|
-
out.log(
|
|
330
|
-
`Delivered directly to ${resolvedRecipient} — it is waiting in their LearnCard wallet.`
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
out.log(`Reusable template for this badge: ${result.uri}`);
|
|
334
|
-
const filename = useTemplate ? 'send-from-template.mjs' : 'send.mjs';
|
|
335
|
-
const sendPath = path.join(cwd, filename);
|
|
336
|
-
let wroteSendFile = false;
|
|
337
|
-
if (!(await fs.stat(sendPath).catch(() => null))) {
|
|
338
|
-
let content = useTemplate
|
|
339
|
-
? personalizeSendFromTemplateMjs(effectiveSendOptions.options)
|
|
340
|
-
: personalizeSendMjs(identity.displayName, badge, effectiveSendOptions.options);
|
|
341
|
-
content = localizeSnippet(content, resolveServices(project.env, options.network));
|
|
342
|
-
if (asManaged) {
|
|
343
|
-
const managedDid = learnCard.id.did();
|
|
344
|
-
await saveProject(project, { MANAGED_DID: managedDid });
|
|
345
|
-
content = withManagedIssuer(content, managedDid);
|
|
346
|
-
}
|
|
347
|
-
await fs.writeFile(sendPath, content);
|
|
348
|
-
wroteSendFile = true;
|
|
349
|
-
out.log(
|
|
350
|
-
`\nThe code that just ran is in ./${filename} — run it yourself:\n npm install @learncard/init\n node --env-file=.env ${filename} ${resolvedRecipient}`
|
|
351
|
-
);
|
|
352
|
-
} else if (asManaged) {
|
|
353
|
-
out.log(
|
|
354
|
-
`Existing ./${filename} was not changed and may use a different issuer. Repeat this send with the CLI --as ${options.as} instead.`
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
out.log(`Check whether it was claimed: npx @learncard/cli status ${result.activityId}`);
|
|
358
|
-
out.log(`See it in the app: npx @learncard/cli open${options.template ? ' template' : ''}`);
|
|
359
|
-
out.set({
|
|
360
|
-
profileId: options.as ?? identity.profileId,
|
|
361
|
-
...(options.as && { onBehalfOf: identity.profileId }),
|
|
362
|
-
did: learnCard.id.did(),
|
|
363
|
-
recipient: resolvedRecipient,
|
|
364
|
-
recipientKind,
|
|
365
|
-
status: result.inbox?.status === 'PENDING' ? 'PENDING' : 'ISSUED',
|
|
366
|
-
...(result.inbox?.claimUrl && { claimUrl: result.inbox.claimUrl }),
|
|
367
|
-
templateUri: result.uri,
|
|
368
|
-
activityId: result.activityId,
|
|
369
|
-
...(result.credentialUri && { credentialUri: result.credentialUri }),
|
|
370
|
-
...(result.inbox?.issuanceId && { issuanceId: result.inbox.issuanceId }),
|
|
371
|
-
files: wroteSendFile ? [`./${filename}`] : [],
|
|
372
|
-
});
|
|
373
|
-
};
|