@learncard/cli 3.5.1 → 3.6.0

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +230 -2
  3. package/dist/index.js +4337 -986
  4. package/examples/branded.network.yaml +20 -0
  5. package/examples/delegated-service-account.network.yaml +23 -0
  6. package/examples/minimal.network.yaml +7 -0
  7. package/examples/self-hosted-signing.network.yaml +10 -0
  8. package/examples/service-account.network.yaml +13 -0
  9. package/examples/state-districts.network.yaml +36 -0
  10. package/package.json +21 -17
  11. package/src/auth-grant.test.ts +54 -0
  12. package/src/auth-grant.ts +34 -0
  13. package/src/clr/validate.test.ts +65 -0
  14. package/src/clr/validate.ts +242 -0
  15. package/src/clr.ts +119 -0
  16. package/src/demo-inbox-refresh.test.ts +737 -0
  17. package/src/demo-inbox-refresh.ts +804 -0
  18. package/src/demo-refresh-command.test.ts +57 -0
  19. package/src/demo-refresh-command.ts +22 -0
  20. package/src/demo-refresh-ui.test.ts +66 -0
  21. package/src/demo-refresh-ui.ts +65 -0
  22. package/src/demo-refresh.test.ts +140 -0
  23. package/src/demo-refresh.ts +309 -0
  24. package/src/doctor/checks.test.ts +448 -0
  25. package/src/doctor/checks.ts +497 -0
  26. package/src/doctor.test.ts +67 -0
  27. package/src/doctor.ts +118 -0
  28. package/src/inbox.test.ts +257 -0
  29. package/src/inbox.ts +221 -0
  30. package/src/index.tsx +70 -8
  31. package/src/init.ts +1 -1
  32. package/src/open.ts +1 -1
  33. package/src/org/apply.test.ts +1108 -0
  34. package/src/org/apply.ts +924 -0
  35. package/src/org/branding.test.ts +60 -0
  36. package/src/org/diff.ts +14 -0
  37. package/src/org/load.ts +50 -0
  38. package/src/org/schema.test.ts +256 -0
  39. package/src/org/schema.ts +216 -0
  40. package/src/org.ts +124 -0
  41. package/src/project.test.ts +26 -1
  42. package/src/project.ts +105 -10
  43. package/src/promote.test.ts +142 -0
  44. package/src/promote.ts +202 -0
  45. package/src/refresh.test.ts +86 -0
  46. package/src/refresh.ts +93 -0
  47. package/src/send.test.ts +278 -2
  48. package/src/send.ts +152 -24
  49. package/src/setup-signing.ts +1 -1
  50. package/src/status.ts +2 -4
  51. package/src/whoami.test.ts +67 -0
  52. package/src/whoami.ts +129 -0
  53. package/tsconfig.json +1 -1
@@ -0,0 +1,804 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import type { AddPlugin } from '@learncard/core';
4
+ import { initLearnCard, type NetworkLearnCardFromSeed } from '@learncard/init';
5
+ import { getLCAPlugin, type LCAPlugin } from '@learncard/lca-api-plugin';
6
+ import { getClient } from '@learncard/network-brain-client';
7
+ import {
8
+ ContactMethodQueryValidator,
9
+ VCValidator,
10
+ type InboxCredentialRefreshReceipt,
11
+ type UnsignedVC,
12
+ type VC,
13
+ type VP,
14
+ } from '@learncard/types';
15
+ import { generateRandomSeed } from './random';
16
+ import { out } from './out';
17
+ import { resolveServices } from './project';
18
+ import { getRefreshDemoUiConfig, requireLoopbackUrl } from './demo-refresh-ui';
19
+
20
+ export interface InboxRefreshDemoOptions {
21
+ network?: string;
22
+ yes?: boolean;
23
+ json?: boolean;
24
+ didkit?: Promise<Buffer>;
25
+ ui?: boolean;
26
+ appUrl?: string;
27
+ lcaUrl?: string;
28
+ /**
29
+ * Opt-in real-email mode. A string is the address to use; `true` means the address
30
+ * was requested without a value and must be prompted for. Requires `--inbox --ui`
31
+ * and an interactive terminal.
32
+ */
33
+ email?: string | boolean;
34
+ }
35
+
36
+ type InboxIssuer = AddPlugin<NetworkLearnCardFromSeed['returnValue'], LCAPlugin>;
37
+
38
+ const LOCAL_NETWORK = 'http://localhost:4000/trpc';
39
+ const DEFAULT_LCA = 'http://localhost:5100/trpc';
40
+ const BEFORE = 'Provisional Course Certificate';
41
+ const AFTER = 'Final Course Certificate';
42
+ const HONORS = 'Honors Course Certificate';
43
+
44
+ /** The generated claim link is served by the backend; the app serves the same path. */
45
+ export const mapInboxClaimPath = (claimUrl: string): string => {
46
+ let url: URL;
47
+ try {
48
+ url = new URL(claimUrl);
49
+ } catch {
50
+ throw new Error('The inbox claim link was not a valid URL.');
51
+ }
52
+ if (!/^\/interactions\/inbox-claim\/[^/]+$/.test(url.pathname)) {
53
+ throw new Error('The inbox claim link had an unexpected path; refusing to open it.');
54
+ }
55
+ return `${url.pathname}${url.search}`;
56
+ };
57
+
58
+ /** Sign in and claim links for the same local app, from the fragments the CLI knows. */
59
+ export const buildInboxAppLinks = (
60
+ appOrigin: string,
61
+ claimPath: string,
62
+ seed: string
63
+ ): { signIn: string; claim: string } => {
64
+ const signIn = new URL('/developer/sign-in', appOrigin);
65
+ signIn.searchParams.set('next', claimPath);
66
+ signIn.hash = `seed=${seed}`;
67
+ return { signIn: signIn.href, claim: new URL(claimPath, appOrigin).href };
68
+ };
69
+
70
+ const baseTemplate = (issuerDid: string): UnsignedVC => ({
71
+ '@context': [
72
+ 'https://www.w3.org/ns/credentials/v2',
73
+ 'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json',
74
+ 'https://ctx.learncard.com/boosts/1.0.1.json',
75
+ ],
76
+ type: ['VerifiableCredential', 'OpenBadgeCredential', 'BoostCredential'],
77
+ name: BEFORE,
78
+ issuer: issuerDid,
79
+ credentialSubject: {
80
+ type: ['AchievementSubject'],
81
+ achievement: {
82
+ id: `urn:uuid:${randomUUID()}`,
83
+ type: ['Achievement'],
84
+ name: 'Introduction to Biology — Provisional Results',
85
+ description: 'Coursework submitted. Final grade: Pending. Results await review.',
86
+ criteria: {
87
+ narrative: 'Final results require review of coursework and the final assessment.',
88
+ },
89
+ },
90
+ },
91
+ });
92
+
93
+ /**
94
+ * Rebuild a complete unsigned version from the issuer's own template and the
95
+ * metadata-only receipt. The issuer cannot read the credential encrypted for the
96
+ * recipient, so the receipt's identity and status descriptors are the source of truth.
97
+ */
98
+ const buildVersion = (
99
+ template: UnsignedVC,
100
+ receipt: InboxCredentialRefreshReceipt,
101
+ boostUri: string,
102
+ input: {
103
+ name: string;
104
+ achievementName: string;
105
+ achievementDescription: string;
106
+ holderDid?: string;
107
+ }
108
+ ): UnsignedVC => {
109
+ // The base template always carries a single AchievementSubject; narrow the
110
+ // validator's union so updates can replace the achievement in place.
111
+ const subject = template.credentialSubject as Record<string, unknown> & {
112
+ achievement?: Record<string, unknown>;
113
+ };
114
+
115
+ return {
116
+ ...template,
117
+ name: input.name,
118
+ id: receipt.credentialId,
119
+ issuer: receipt.issuerDid,
120
+ boostId: boostUri,
121
+ validFrom: new Date().toISOString(),
122
+ refreshService: receipt.refreshService,
123
+ ...(receipt.credentialStatus && { credentialStatus: receipt.credentialStatus }),
124
+ credentialSubject: {
125
+ ...subject,
126
+ ...(input.holderDid && { id: input.holderDid }),
127
+ achievement: {
128
+ ...subject.achievement,
129
+ name: input.achievementName,
130
+ description: input.achievementDescription,
131
+ },
132
+ },
133
+ };
134
+ };
135
+
136
+ /** Build the LearnCard config shared by the issuer and disposable holder wallets. */
137
+ const buildInboxDemoConfig = (network: string, networkUrl: URL, didkit?: Promise<Buffer>) => ({
138
+ network,
139
+ ...(didkit && { didkit }),
140
+ trustedBoostRegistry: `data:application/json,${encodeURIComponent(
141
+ JSON.stringify([
142
+ {
143
+ id: 'Inbox demo network',
144
+ url: networkUrl.origin,
145
+ did: `did:web:${encodeURIComponent(networkUrl.host)}`,
146
+ },
147
+ ])
148
+ )}`,
149
+ });
150
+
151
+ interface InboxDemoEnvironment {
152
+ network: string;
153
+ networkUrl: URL;
154
+ ui?: Awaited<ReturnType<typeof getRefreshDemoUiConfig>>;
155
+ lca: URL;
156
+ interactive: boolean;
157
+ prompts?: ReturnType<typeof createInterface>;
158
+ pause: (next: string) => Promise<void>;
159
+ }
160
+
161
+ /** Validate local-only options and open the app/LCA services before any account is created. */
162
+ const prepareInboxDemoEnvironment = async (
163
+ options: InboxRefreshDemoOptions
164
+ ): Promise<InboxDemoEnvironment> => {
165
+ const { network, lcaAPI: envLca } = resolveServices({}, options.network || LOCAL_NETWORK);
166
+ const networkUrl = requireLoopbackUrl(network, '--network (inbox demo is local-only)');
167
+ const interactive =
168
+ !options.yes && !options.json && !!process.stdin.isTTY && process.env.LC_YES !== '1';
169
+ if (options.ui && !interactive) {
170
+ throw new Error(
171
+ '--ui requires an interactive terminal; omit --yes, --json, and LC_YES=1 so you can claim in the app.'
172
+ );
173
+ }
174
+ if (options.appUrl && !options.ui) throw new Error('--app-url requires --ui.');
175
+ if (options.lcaUrl && options.ui)
176
+ throw new Error(
177
+ '--lca-url is for terminal mode; --ui reads the local LCA service from the app.'
178
+ );
179
+ const ui = options.ui
180
+ ? await getRefreshDemoUiConfig(options.appUrl ?? 'http://localhost:3000', network)
181
+ : undefined;
182
+ const lca = ui
183
+ ? requireLoopbackUrl(ui.lcaApi, 'App LCA service')
184
+ : requireLoopbackUrl(options.lcaUrl ?? envLca ?? DEFAULT_LCA, '--lca-url');
185
+ const prompts = interactive
186
+ ? createInterface({ input: process.stdin, output: process.stdout })
187
+ : undefined;
188
+ const pause = async (next: string): Promise<void> => {
189
+ if (prompts) await prompts.question(`\nPress Enter to ${next}... `);
190
+ };
191
+ return { network, networkUrl, ui, lca, interactive, prompts, pause };
192
+ };
193
+
194
+ interface InboxIssuerSetup {
195
+ issuer: InboxIssuer;
196
+ signingAuthority: { endpoint: string; name: string };
197
+ template: UnsignedVC;
198
+ boostUri: string;
199
+ suffix: string;
200
+ }
201
+
202
+ /** Create the throwaway demo school and register a real signing authority on the local network. */
203
+ const setupInboxIssuer = async (
204
+ config: ReturnType<typeof buildInboxDemoConfig>,
205
+ lcaHref: string
206
+ ): Promise<InboxIssuerSetup> => {
207
+ const issuerBase = await initLearnCard({ ...config, seed: generateRandomSeed() });
208
+ const issuer = (await issuerBase.addPlugin(
209
+ await getLCAPlugin(issuerBase as unknown as Parameters<typeof getLCAPlugin>[0], lcaHref)
210
+ )) as unknown as InboxIssuer;
211
+ const suffix = randomUUID().slice(0, 8);
212
+ await issuer.invoke.createProfile({
213
+ profileId: `inbox-issuer-${suffix}`,
214
+ displayName: 'Inbox Demo School',
215
+ bio: '',
216
+ shortBio: '',
217
+ });
218
+
219
+ const authority = await issuer.invoke.createSigningAuthority(`inbox-${suffix}`);
220
+ if (!authority || !authority.endpoint || !authority.did) {
221
+ throw new Error('The LCA service did not return a signing authority.');
222
+ }
223
+ if (
224
+ !(await issuer.invoke.registerSigningAuthority(
225
+ authority.endpoint,
226
+ authority.name,
227
+ authority.did
228
+ ))
229
+ ) {
230
+ throw new Error('Could not register the signing authority.');
231
+ }
232
+ if (
233
+ !(await issuer.invoke.setPrimaryRegisteredSigningAuthority(
234
+ authority.endpoint,
235
+ authority.name
236
+ ))
237
+ ) {
238
+ throw new Error('Could not select the signing authority.');
239
+ }
240
+ const signingAuthority = { endpoint: authority.endpoint, name: authority.name };
241
+ const template = baseTemplate(issuer.id.did());
242
+ const boostUri = await issuer.invoke.createBoost(template, { category: 'Achievement' });
243
+ return { issuer, signingAuthority, template, boostUri, suffix };
244
+ };
245
+
246
+ /** Mask the local part of an address before writing it to machine-readable output. */
247
+ const maskEmail = (email: string): string => {
248
+ const [local, domain] = email.split('@');
249
+ if (!local || !domain) return '***';
250
+ const visible = local.slice(0, 1);
251
+ return `${visible}${'*'.repeat(Math.max(local.length - 1, 1))}@${domain}`;
252
+ };
253
+
254
+ /**
255
+ * Real-email Universal Inbox walkthrough (LC-2198 opt-in).
256
+ *
257
+ * Unlike {@link runInboxRefreshDemo}, this never creates or reads a recipient wallet. The
258
+ * operator's delivery service is asked to email a provisional claim link to an address the
259
+ * presenter owns; after a human claims it, the school publishes exactly one visible update
260
+ * (version 2, final grade A) to the holder DID the issuer reports. The CLI confirms only
261
+ * what the issuer reports, never the recipient's wallet contents or email delivery.
262
+ */
263
+ export const runEmailInboxRefreshDemo = async (options: InboxRefreshDemoOptions): Promise<void> => {
264
+ const provided = typeof options.email === 'string' ? options.email.trim() : '';
265
+ if (provided) {
266
+ const parsed = ContactMethodQueryValidator.safeParse({ type: 'email', value: provided });
267
+ if (!parsed.success) throw new Error('Enter a valid email address you own.');
268
+ }
269
+
270
+ const env = await prepareInboxDemoEnvironment(options);
271
+ let step = 'start the real-email walkthrough';
272
+
273
+ try {
274
+ if (!env.ui) {
275
+ throw new Error(
276
+ '--email requires --ui so you can sign in with the address that receives the claim.'
277
+ );
278
+ }
279
+ const prompts = env.prompts;
280
+ if (!prompts) {
281
+ throw new Error(
282
+ '--email requires an interactive terminal; omit --yes, --json, and LC_YES=1 so you can claim in the app.'
283
+ );
284
+ }
285
+ const pause = env.pause;
286
+ out.log('\nLearnCard: receive and update a certificate by email\n');
287
+ out.log('1. Enter your email. The demo school sends a provisional certificate.');
288
+ out.log('2. Open the email, sign in or create an account with that address, and claim it.');
289
+ out.log('3. Return here to publish final results. See the update in the app and by email.');
290
+ out.log('Open email links on this computer, where the local app is running.');
291
+ out.log('If the app is signed in to a disposable demo account, sign out first.');
292
+
293
+ let email = provided;
294
+ if (!email) {
295
+ out.log('\nEnter an email address you own and can open right now.');
296
+ email = (await prompts.question('Email address: ')).trim();
297
+ }
298
+ const parsed = ContactMethodQueryValidator.safeParse({ type: 'email', value: email });
299
+ if (!parsed.success) throw new Error('Enter a valid email address you own.');
300
+ email = parsed.data.value;
301
+
302
+ out.log(`Network: ${env.network}`);
303
+ out.log(`Inbox claim service: ${env.lca.href}`);
304
+ out.log(`Real email requested for: ${email}`);
305
+ out.log('Email delivery uses your local Postmark configuration; check your mailbox.');
306
+ await pause('start');
307
+
308
+ out.log('\nSetting up the issuer and signing authority...');
309
+ step = 'register a signing authority';
310
+ const config = buildInboxDemoConfig(env.network, env.networkUrl, options.didkit);
311
+ const { issuer, signingAuthority, template, boostUri, suffix } = await setupInboxIssuer(
312
+ config,
313
+ env.lca.href
314
+ );
315
+
316
+ step = 'request provisional delivery';
317
+ out.log('\n1 / 3 ISSUE PROVISIONAL RESULTS');
318
+ out.log(`Requesting delivery of a provisional claim link for ${email}.`);
319
+ const issued = await issuer.invoke.sendCredentialViaInbox({
320
+ recipient: { type: 'email', value: email },
321
+ templateUri: boostUri,
322
+ refresh: true,
323
+ idempotencyKey: `inbox-email-issue-${suffix}`,
324
+ configuration: {
325
+ signingAuthority,
326
+ // Real-email mode never suppresses; the operator's adapter decides delivery.
327
+ delivery: { suppress: false },
328
+ },
329
+ });
330
+ const receipt = issued.refresh;
331
+ if (!receipt) throw new Error('The SDK did not return an inbox refresh receipt.');
332
+ if (!['PENDING', 'ISSUED', 'DELIVERED'].includes(issued.status)) {
333
+ throw new Error(
334
+ `Expected a pending or delivered inbox credential, received ${issued.status}.`
335
+ );
336
+ }
337
+ const alreadyKnown = !!receipt.holderDid;
338
+ if (alreadyKnown) {
339
+ out.log(
340
+ 'That address already has a LearnCard account. Open the email, sign in, then claim the provisional certificate in Alerts.'
341
+ );
342
+ } else {
343
+ out.log(
344
+ 'The provisional certificate is ready. Open the claim email to sign in or create your account.'
345
+ );
346
+ if (issued.claimUrl) {
347
+ out.log(
348
+ `If the email does not arrive, the local inbox service reported this claim link:\n${issued.claimUrl}`
349
+ );
350
+ }
351
+ }
352
+
353
+ await pause(
354
+ alreadyKnown
355
+ ? 'open the email, sign in with that address, and claim the provisional certificate'
356
+ : 'open the mailbox link, sign in or create an account with that address, and claim the provisional credential'
357
+ );
358
+
359
+ step = 'detect the claim';
360
+ out.log('\n2 / 3 CLAIM THE PROVISIONAL RESULTS');
361
+ let holderDid = receipt.holderDid;
362
+ let status: string = issued.status;
363
+ do {
364
+ const metadata = await issuer.invoke.getInboxCredential(issued.issuanceId);
365
+ holderDid = metadata?.refresh?.holderDid ?? holderDid;
366
+ status = metadata?.currentStatus ?? status;
367
+ if (!holderDid) {
368
+ out.log(
369
+ 'The claim is not bound yet. Finish claiming with that address, then press Enter.'
370
+ );
371
+ await pause('check the inbox credential again');
372
+ }
373
+ } while (!holderDid);
374
+ out.log(
375
+ `The issuer reports the claim as bound (${status}). This CLI did not read the recipient wallet.`
376
+ );
377
+
378
+ step = 'check recipient account setup';
379
+ let holderProfile;
380
+ try {
381
+ holderProfile = await issuer.invoke.getProfile(holderDid);
382
+ } catch (error) {
383
+ // A missing account is different from an unavailable network: only
384
+ // NOT_FOUND means setup is missing; transport errors remain errors.
385
+ if ((error as { data?: { code?: string } })?.data?.code !== 'NOT_FOUND') throw error;
386
+ }
387
+ if (!holderProfile) {
388
+ throw new Error(
389
+ 'The certificate was claimed before account setup finished. Complete account setup in the app, then start a fresh email demo so notifications can reach your account. No update was published.'
390
+ );
391
+ }
392
+
393
+ step = 'publish final results';
394
+ out.log('\n3 / 3 PUBLISH FINAL RESULTS');
395
+ out.log('Publishing grade-A final results as the single visible update (version 2)...');
396
+ const finalVersion = buildVersion(template, receipt, boostUri, {
397
+ name: AFTER,
398
+ achievementName: 'Introduction to Biology — Final Results',
399
+ achievementDescription:
400
+ 'Course completed. Final grade: A. Coursework and final assessment reviewed.',
401
+ holderDid,
402
+ });
403
+ const publication = await issuer.invoke.publishCredentialRefresh({
404
+ refreshId: receipt.refreshId,
405
+ mode: 'signing-authority',
406
+ credential: finalVersion,
407
+ signingAuthority: { type: 'http', ...signingAuthority },
408
+ updateSummary: 'Final results are ready. Final grade: A.',
409
+ idempotencyKey: `inbox-email-final-${suffix}`,
410
+ });
411
+ if (publication.version !== 2) {
412
+ throw new Error(`Expected version 2, received ${publication.version}.`);
413
+ }
414
+ if (publication.notification === 'not-applicable') {
415
+ throw new Error('The claim bound a holder but the update had no notification target.');
416
+ }
417
+ out.log(`Final results published. In-app notification: ${publication.notification}.`);
418
+ if (publication.notification === 'queued') {
419
+ out.log(
420
+ 'Check your email for the school and certificate name. Choose View Updates to open Notifications, then view Final Results / Final grade: A.'
421
+ );
422
+ } else {
423
+ out.log(
424
+ 'The certificate was updated, but an update notification could not be queued. Check account setup and the local notification service before expecting an email.'
425
+ );
426
+ }
427
+ out.set({
428
+ network: env.network,
429
+ issuanceId: issued.issuanceId,
430
+ refreshId: receipt.refreshId,
431
+ recipient: maskEmail(email),
432
+ before: BEFORE,
433
+ after: AFTER,
434
+ version: publication.version,
435
+ status,
436
+ notification: publication.notification,
437
+ realEmail: true,
438
+ deliveryRequested: true,
439
+ });
440
+ } catch (error) {
441
+ const detail = error instanceof Error ? error.message.split('\n')[0] : 'Please try again.';
442
+ throw Object.assign(
443
+ new Error(
444
+ `Could not ${step}: ${detail} Use a running local network with managed refresh and LC-2198 deployed.`
445
+ ),
446
+ { cause: error }
447
+ );
448
+ } finally {
449
+ env.prompts?.close();
450
+ }
451
+ };
452
+
453
+ /**
454
+ * Guided Universal Inbox refresh demonstration (LC-2198).
455
+ *
456
+ * Stage 1 issues a refreshable credential to an email address that has no account.
457
+ * Stage 2 publishes a new version before anyone claims. UI mode then has a human claim
458
+ * in the real app and publish an update to the now-bound holder; terminal mode makes the
459
+ * same claim with DIDAuth and refreshes the local wallet itself.
460
+ *
461
+ * Pass `--email` to opt into {@link runEmailInboxRefreshDemo} instead.
462
+ */
463
+ export const runInboxRefreshDemo = async (options: InboxRefreshDemoOptions): Promise<void> => {
464
+ if (options.email !== undefined) {
465
+ return runEmailInboxRefreshDemo(options);
466
+ }
467
+ const { network, networkUrl, ui, lca, prompts, pause } =
468
+ await prepareInboxDemoEnvironment(options);
469
+ let step = 'connect to the demo network';
470
+
471
+ try {
472
+ out.log('\nLearnCard: watch a credential refresh through the Universal Inbox\n');
473
+ out.log(`Network: ${network}`);
474
+ out.log(`Inbox claim service: ${lca.href}`);
475
+ out.log(
476
+ 'This creates a fresh demo school and a recipient address that does not exist yet.'
477
+ );
478
+ out.log('No email is sent; the claim link stays in this demo.');
479
+ await pause('start');
480
+ out.log('\nSetting up the issuer and signing authority...');
481
+
482
+ step = 'register a signing authority';
483
+ const config = buildInboxDemoConfig(network, networkUrl, options.didkit);
484
+ const { issuer, signingAuthority, template, boostUri, suffix } = await setupInboxIssuer(
485
+ config,
486
+ lca.href
487
+ );
488
+
489
+ const demoEmail = `inbox-demo-${suffix}@example.com`;
490
+ step = 'issue the provisional certificate into the inbox';
491
+ out.log('\n1 / 4 ISSUE PROVISIONAL RESULTS');
492
+ out.log(`Queuing a provisional certificate for ${demoEmail}.`);
493
+ out.log('This address has no LearnCard account yet, so there is nobody to notify.');
494
+ const issued = await issuer.invoke.sendCredentialViaInbox({
495
+ recipient: { type: 'email', value: demoEmail },
496
+ templateUri: boostUri,
497
+ refresh: true,
498
+ idempotencyKey: `inbox-demo-issue-${suffix}`,
499
+ configuration: {
500
+ signingAuthority,
501
+ delivery: { suppress: true },
502
+ },
503
+ });
504
+ const receipt = issued.refresh;
505
+ if (issued.status !== 'PENDING')
506
+ throw new Error(`Expected a PENDING inbox credential, received ${issued.status}.`);
507
+ if (!receipt) throw new Error('The SDK did not return an inbox refresh receipt.');
508
+ if (receipt.holderDid) throw new Error('A holder was bound before anyone claimed.');
509
+ if (!issued.claimUrl) throw new Error('The SDK did not return a claim link.');
510
+ out.log(
511
+ 'Provisional results are queued for the demo address. The recipient has not joined.'
512
+ );
513
+
514
+ await pause('publish final results before anyone claims');
515
+ step = 'publish final results before claim';
516
+ out.log('\n2 / 4 PUBLISH FINAL RESULTS (BEFORE ANY CLAIM)');
517
+ out.log('The school finalizes the certificate while the recipient still has no account...');
518
+ const finalVersion = buildVersion(template, receipt, boostUri, {
519
+ name: AFTER,
520
+ achievementName: 'Introduction to Biology — Final Results',
521
+ achievementDescription:
522
+ 'Course completed. Final grade: A. Coursework and final assessment reviewed.',
523
+ });
524
+ const finalPublication = await issuer.invoke.publishCredentialRefresh({
525
+ refreshId: receipt.refreshId,
526
+ mode: 'signing-authority',
527
+ credential: finalVersion,
528
+ signingAuthority: { type: 'http', ...signingAuthority },
529
+ updateSummary: 'Final results are ready. Final grade: A.',
530
+ idempotencyKey: `inbox-demo-final-${suffix}`,
531
+ });
532
+ if (finalPublication.version !== 2)
533
+ throw new Error(`Expected version 2, received ${finalPublication.version}.`);
534
+ if (finalPublication.notification !== 'not-applicable')
535
+ throw new Error(
536
+ `Expected no pre-claim notification, received ${finalPublication.notification}.`
537
+ );
538
+ out.log('Version 2 is queued. Claiming will deliver the newest content, not the original.');
539
+
540
+ await pause(
541
+ ui
542
+ ? 'create the recipient demo account and show the claim link'
543
+ : 'claim the final certificate'
544
+ );
545
+ if (ui) {
546
+ step = 'prepare the recipient app session';
547
+ out.log('\n3 / 4 CLAIM IN THE APP');
548
+ out.log(
549
+ 'Creating the recipient demo account now that the pre-claim update is published...'
550
+ );
551
+ const holderSeed = generateRandomSeed();
552
+ const holder = await initLearnCard({
553
+ ...config,
554
+ seed: holderSeed,
555
+ network,
556
+ cloud: { url: ui.cloud },
557
+ });
558
+ await holder.invoke.createProfile({
559
+ profileId: `inbox-learner-${suffix}`,
560
+ displayName: 'Inbox Demo Learner',
561
+ bio: '',
562
+ shortBio: '',
563
+ notificationsWebhook: ui.notificationsWebhook,
564
+ locale: 'en',
565
+ });
566
+ const claimPath = mapInboxClaimPath(issued.claimUrl);
567
+ const links = buildInboxAppLinks(ui.appOrigin, claimPath, holderSeed);
568
+ out.log(`\nOpen this link to sign in as Inbox Demo Learner:\n${links.signIn}`);
569
+ out.log(`Claim link for the same app:\n${links.claim}`);
570
+ out.log(
571
+ 'This link controls a disposable demo account. Keep it private and use it only for test data.'
572
+ );
573
+ out.log(
574
+ 'This first delivery arrives as a claim link, not an alert: the recipient did not exist when it was issued.'
575
+ );
576
+ out.log('If already signed in, choose the demo account switch. Keep the app open.');
577
+
578
+ const readAppCredentials = async (): Promise<VC[]> => {
579
+ const records = await holder.index.LearnCloud.get({});
580
+ const matches: VC[] = [];
581
+ for (const record of records ?? []) {
582
+ const parsed = VCValidator.safeParse(await holder.read.get(record.uri));
583
+ if (parsed.success && parsed.data.id === receipt.credentialId)
584
+ matches.push(parsed.data);
585
+ }
586
+ return matches;
587
+ };
588
+ const verifySaved = async (credential: VC, expected: string): Promise<void> => {
589
+ if (credential.name !== expected)
590
+ throw new Error('The app saved a different version than expected.');
591
+ // The CLI shares a resolver with issuer setup, which can predate delegate registration.
592
+ await holder.invoke.resolveDid(receipt.issuerDid, { noCache: true });
593
+ const proof = await holder.invoke.verifyCredential(credential);
594
+ if (
595
+ proof.errors.length ||
596
+ proof.warnings.length ||
597
+ !proof.checks.includes('proof')
598
+ ) {
599
+ throw new Error('The app’s certificate did not pass verification.');
600
+ }
601
+ };
602
+
603
+ let claimed = false;
604
+ do {
605
+ await pause('open the claim link, sign in, and confirm the certificate is claimed');
606
+ const matches = await readAppCredentials();
607
+ if (matches.length === 0) {
608
+ out.log(
609
+ 'The credential is not saved in the demo account yet. Finish the claim first.'
610
+ );
611
+ continue;
612
+ }
613
+ if (matches.length > 1) {
614
+ out.log(
615
+ 'The app has more than one copy of this credential. Remove extras first.'
616
+ );
617
+ continue;
618
+ }
619
+ const saved = matches[0]!;
620
+ if (saved.name !== AFTER) {
621
+ out.log('The app still shows an older version. Finish the claim, then retry.');
622
+ continue;
623
+ }
624
+ await verifySaved(saved, AFTER);
625
+ claimed = true;
626
+ } while (!claimed);
627
+ out.log('Final results are saved under the credential ID from the receipt.');
628
+
629
+ step = 'publish honors results to the claimed holder';
630
+ out.log('\n4 / 4 PUBLISH HONORS RESULTS');
631
+ const metadata = await issuer.invoke.getInboxCredential(issued.issuanceId);
632
+ const holderDid = metadata?.refresh?.holderDid;
633
+ if (!holderDid)
634
+ throw new Error('The claim did not bind a holder DID to the refresh receipt.');
635
+ if (holderDid !== holder.id.did())
636
+ throw new Error('The bound recipient did not match the demo account.');
637
+ await pause('publish the honors update to the claimed certificate');
638
+ const honorsVersion = buildVersion(template, receipt, boostUri, {
639
+ name: HONORS,
640
+ achievementName: 'Introduction to Biology — Honors Results',
641
+ achievementDescription:
642
+ 'Outstanding work. Final grade: A+. Coursework and final assessment reviewed with distinction.',
643
+ holderDid,
644
+ });
645
+ const honorsPublication = await issuer.invoke.publishCredentialRefresh({
646
+ refreshId: receipt.refreshId,
647
+ mode: 'signing-authority',
648
+ credential: honorsVersion,
649
+ signingAuthority: { type: 'http', ...signingAuthority },
650
+ updateSummary: 'Honors results are ready. Final grade: A+.',
651
+ idempotencyKey: `inbox-demo-honors-${suffix}`,
652
+ });
653
+ if (honorsPublication.version !== 3)
654
+ throw new Error(`Expected version 3, received ${honorsPublication.version}.`);
655
+ out.log('The update is queued to the claimed holder.');
656
+
657
+ let updated = false;
658
+ do {
659
+ await pause(
660
+ 'tap the update notification in the app, then confirm the honors certificate'
661
+ );
662
+ const matches = await readAppCredentials();
663
+ if (matches.length === 0) {
664
+ out.log(
665
+ 'The update is not saved in the app yet. Tap the notification and wait.'
666
+ );
667
+ continue;
668
+ }
669
+ if (matches.length > 1) {
670
+ out.log('The app created a duplicate instead of updating the existing entry.');
671
+ continue;
672
+ }
673
+ const saved = matches[0]!;
674
+ if (saved.name !== HONORS) {
675
+ out.log('The app still shows final results. Tap the update notification.');
676
+ continue;
677
+ }
678
+ await verifySaved(saved, HONORS);
679
+ updated = true;
680
+ } while (!updated);
681
+ out.log(
682
+ '\nVerified: the app replaced the same credential entry with the honors certificate.'
683
+ );
684
+ out.log('You can close this terminal; the recipient stays signed in.');
685
+ out.set({
686
+ network,
687
+ issuanceId: issued.issuanceId,
688
+ refreshId: receipt.refreshId,
689
+ before: AFTER,
690
+ after: HONORS,
691
+ version: honorsPublication.version,
692
+ status: 'updated',
693
+ sameCredentialId: true,
694
+ ui: true,
695
+ });
696
+ return;
697
+ }
698
+
699
+ step = 'claim the final certificate with DIDAuth';
700
+ out.log('\n3 / 4 CLAIM THE FINAL CERTIFICATE');
701
+ out.log('Claiming with a fresh local wallet, exactly as the app does...');
702
+ const holder = await initLearnCard({ ...config, seed: generateRandomSeed(), network });
703
+ const client = await getClient(
704
+ network,
705
+ async (challenge?: string) =>
706
+ (await holder.invoke.getDidAuthVp({ proofFormat: 'jwt', challenge })) as string
707
+ );
708
+ const localExchangeId = new URL(issued.claimUrl).pathname.split('/').pop();
709
+ if (!localExchangeId) throw new Error('The claim link did not include an exchange id.');
710
+ const challenge = await client.workflows.participateInExchange.mutate({
711
+ localWorkflowId: 'inbox-claim',
712
+ localExchangeId,
713
+ });
714
+ const vp = (await holder.invoke.getDidAuthVp({
715
+ challenge: challenge.verifiablePresentationRequest?.challenge,
716
+ domain: challenge.verifiablePresentationRequest?.domain,
717
+ })) as VP;
718
+ const claim = await client.workflows.participateInExchange.mutate({
719
+ localWorkflowId: 'inbox-claim',
720
+ localExchangeId,
721
+ verifiablePresentation: vp,
722
+ });
723
+ const claimedCredentials = claim.verifiablePresentation?.verifiableCredential;
724
+ const claimed = (
725
+ Array.isArray(claimedCredentials) ? claimedCredentials[0] : claimedCredentials
726
+ ) as VC | undefined;
727
+ if (!claimed) throw new Error('The claim did not return a credential.');
728
+ if (claimed.id !== receipt.credentialId)
729
+ throw new Error('The claim returned a different credential identity.');
730
+ if (claimed.name !== AFTER)
731
+ throw new Error(`The claim returned an unexpected version: ${claimed.name}.`);
732
+ await holder.invoke.resolveDid(receipt.issuerDid, { noCache: true });
733
+ const proof = await holder.invoke.verifyCredential(claimed);
734
+ if (proof.errors.length || proof.warnings.length || !proof.checks.includes('proof')) {
735
+ throw new Error('The claimed certificate did not pass verification.');
736
+ }
737
+ out.log('Claimed and verified: same credential ID, final grade A, valid proof.');
738
+
739
+ await pause('publish honors and refresh the claimed certificate');
740
+ step = 'publish honors results to the bound holder';
741
+ out.log('\n4 / 4 PUBLISH HONORS RESULTS');
742
+ const metadata = await issuer.invoke.getInboxCredential(issued.issuanceId);
743
+ const holderDid = metadata?.refresh?.holderDid;
744
+ if (!holderDid)
745
+ throw new Error('The claim did not bind a holder DID to the refresh receipt.');
746
+ if (holderDid !== holder.id.did())
747
+ throw new Error('The bound holder DID did not match the claiming wallet.');
748
+ const honorsVersion = buildVersion(template, receipt, boostUri, {
749
+ name: HONORS,
750
+ achievementName: 'Introduction to Biology — Honors Results',
751
+ achievementDescription:
752
+ 'Outstanding work. Final grade: A+. Coursework and final assessment reviewed with distinction.',
753
+ holderDid,
754
+ });
755
+ const honorsPublication = await issuer.invoke.publishCredentialRefresh({
756
+ refreshId: receipt.refreshId,
757
+ mode: 'signing-authority',
758
+ credential: honorsVersion,
759
+ signingAuthority: { type: 'http', ...signingAuthority },
760
+ updateSummary: 'Honors results are ready. Final grade: A+.',
761
+ idempotencyKey: `inbox-demo-honors-${suffix}`,
762
+ // Terminal-only recipients have no app profile/webhook; refresh directly below.
763
+ notifyHolder: false,
764
+ });
765
+ if (honorsPublication.version !== 3)
766
+ throw new Error(`Expected version 3, received ${honorsPublication.version}.`);
767
+
768
+ out.log('Refreshing the recipient’s copy...');
769
+ const refreshed = await holder.invoke.refreshCredential(claimed, {
770
+ allowInsecureHttp: true,
771
+ allowPrivateAddresses: true,
772
+ maxRedirects: 0,
773
+ });
774
+ if (refreshed.status !== 'updated')
775
+ throw new Error(`Refresh did not return an update (${refreshed.status}).`);
776
+ if (refreshed.credential.name !== HONORS || refreshed.credential.id !== claimed.id) {
777
+ throw new Error('The refreshed certificate did not match the published update.');
778
+ }
779
+ out.log(`\nBefore: "${claimed.name}"`);
780
+ out.log(`After: "${refreshed.credential.name}"`);
781
+ out.log('Verified update. Same credential identity. Claimed once.');
782
+ out.set({
783
+ network,
784
+ issuanceId: issued.issuanceId,
785
+ refreshId: receipt.refreshId,
786
+ before: AFTER,
787
+ after: refreshed.credential.name,
788
+ version: honorsPublication.version,
789
+ notification: honorsPublication.notification,
790
+ status: refreshed.status,
791
+ sameCredentialId: true,
792
+ });
793
+ } catch (error) {
794
+ const detail = error instanceof Error ? error.message.split('\n')[0] : 'Please try again.';
795
+ throw Object.assign(
796
+ new Error(
797
+ `Could not ${step}: ${detail} Use a running local network with managed refresh and LC-2198 deployed.`
798
+ ),
799
+ { cause: error }
800
+ );
801
+ } finally {
802
+ prompts?.close();
803
+ }
804
+ };