@learncard/cli 3.5.0 → 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.
- package/CHANGELOG.md +61 -0
- package/README.md +230 -2
- package/dist/index.js +4337 -986
- package/examples/branded.network.yaml +20 -0
- package/examples/delegated-service-account.network.yaml +23 -0
- package/examples/minimal.network.yaml +7 -0
- package/examples/self-hosted-signing.network.yaml +10 -0
- package/examples/service-account.network.yaml +13 -0
- package/examples/state-districts.network.yaml +36 -0
- package/package.json +21 -17
- package/src/auth-grant.test.ts +54 -0
- package/src/auth-grant.ts +34 -0
- package/src/clr/validate.test.ts +65 -0
- package/src/clr/validate.ts +242 -0
- package/src/clr.ts +119 -0
- package/src/demo-inbox-refresh.test.ts +737 -0
- package/src/demo-inbox-refresh.ts +804 -0
- package/src/demo-refresh-command.test.ts +57 -0
- package/src/demo-refresh-command.ts +22 -0
- package/src/demo-refresh-ui.test.ts +66 -0
- package/src/demo-refresh-ui.ts +65 -0
- package/src/demo-refresh.test.ts +140 -0
- package/src/demo-refresh.ts +309 -0
- package/src/doctor/checks.test.ts +448 -0
- package/src/doctor/checks.ts +497 -0
- package/src/doctor.test.ts +67 -0
- package/src/doctor.ts +118 -0
- package/src/inbox.test.ts +257 -0
- package/src/inbox.ts +221 -0
- package/src/index.tsx +70 -8
- package/src/init.ts +1 -1
- package/src/open.ts +1 -1
- package/src/org/apply.test.ts +1108 -0
- package/src/org/apply.ts +924 -0
- package/src/org/branding.test.ts +60 -0
- package/src/org/diff.ts +14 -0
- package/src/org/load.ts +50 -0
- package/src/org/schema.test.ts +256 -0
- package/src/org/schema.ts +216 -0
- package/src/org.ts +124 -0
- package/src/project.test.ts +26 -1
- package/src/project.ts +105 -10
- package/src/promote.test.ts +142 -0
- package/src/promote.ts +202 -0
- package/src/refresh.test.ts +86 -0
- package/src/refresh.ts +93 -0
- package/src/send.test.ts +278 -2
- package/src/send.ts +152 -24
- package/src/setup-signing.ts +1 -1
- package/src/status.ts +2 -4
- package/src/whoami.test.ts +67 -0
- package/src/whoami.ts +129 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({ direct: vi.fn(), inbox: vi.fn() }));
|
|
4
|
+
|
|
5
|
+
vi.mock('./demo-refresh', () => ({ runRefreshDemo: mocks.direct }));
|
|
6
|
+
vi.mock('./demo-inbox-refresh', () => ({ runInboxRefreshDemo: mocks.inbox }));
|
|
7
|
+
|
|
8
|
+
import { runDemoRefreshCommand } from './demo-refresh-command';
|
|
9
|
+
|
|
10
|
+
beforeEach(() => vi.resetAllMocks());
|
|
11
|
+
|
|
12
|
+
describe('demo refresh command forwarding', () => {
|
|
13
|
+
it('routes --inbox to the Universal Inbox demonstration', async () => {
|
|
14
|
+
mocks.inbox.mockResolvedValue(undefined);
|
|
15
|
+
const options = { inbox: true, network: 'http://localhost:4000/trpc' };
|
|
16
|
+
await runDemoRefreshCommand(options);
|
|
17
|
+
expect(mocks.inbox).toHaveBeenCalledWith(options);
|
|
18
|
+
expect(mocks.direct).not.toHaveBeenCalled();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('runs the existing direct-send demonstration when --inbox is absent', async () => {
|
|
22
|
+
mocks.direct.mockResolvedValue(undefined);
|
|
23
|
+
const options = { network: 'http://localhost:4000/trpc' };
|
|
24
|
+
await runDemoRefreshCommand(options);
|
|
25
|
+
expect(mocks.direct).toHaveBeenCalledWith(options);
|
|
26
|
+
expect(mocks.inbox).not.toHaveBeenCalled();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects --email without --inbox before loading a demo', async () => {
|
|
30
|
+
await expect(
|
|
31
|
+
runDemoRefreshCommand({ email: 'owner@example.com', ui: true })
|
|
32
|
+
).rejects.toThrow('--email requires --inbox');
|
|
33
|
+
expect(mocks.inbox).not.toHaveBeenCalled();
|
|
34
|
+
expect(mocks.direct).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('rejects --email without --ui', async () => {
|
|
38
|
+
await expect(
|
|
39
|
+
runDemoRefreshCommand({ email: 'owner@example.com', inbox: true })
|
|
40
|
+
).rejects.toThrow('--email requires --ui');
|
|
41
|
+
expect(mocks.inbox).not.toHaveBeenCalled();
|
|
42
|
+
expect(mocks.direct).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('routes --email with --inbox --ui to the Universal Inbox demonstration', async () => {
|
|
46
|
+
mocks.inbox.mockResolvedValue(undefined);
|
|
47
|
+
const options = {
|
|
48
|
+
inbox: true,
|
|
49
|
+
ui: true,
|
|
50
|
+
email: 'owner@example.com',
|
|
51
|
+
network: 'http://localhost:4000/trpc',
|
|
52
|
+
};
|
|
53
|
+
await runDemoRefreshCommand(options);
|
|
54
|
+
expect(mocks.inbox).toHaveBeenCalledWith(options);
|
|
55
|
+
expect(mocks.direct).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { InboxRefreshDemoOptions } from './demo-inbox-refresh';
|
|
2
|
+
|
|
3
|
+
export type DemoRefreshCommandOptions = InboxRefreshDemoOptions & { inbox?: boolean };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Route `demo refresh` to the direct-send demo, the Universal Inbox demo, or the
|
|
7
|
+
* opt-in real-email walkthrough. Kept separate so the command-line forwarding is
|
|
8
|
+
* unit-testable without a live network.
|
|
9
|
+
*/
|
|
10
|
+
export const runDemoRefreshCommand = async (options: DemoRefreshCommandOptions): Promise<void> => {
|
|
11
|
+
if (options.email !== undefined) {
|
|
12
|
+
if (!options.inbox) throw new Error('--email requires --inbox.');
|
|
13
|
+
if (!options.ui) throw new Error('--email requires --ui.');
|
|
14
|
+
}
|
|
15
|
+
if (options.inbox) {
|
|
16
|
+
const { runInboxRefreshDemo } = await import('./demo-inbox-refresh');
|
|
17
|
+
await runInboxRefreshDemo(options);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const { runRefreshDemo } = await import('./demo-refresh');
|
|
21
|
+
await runRefreshDemo(options);
|
|
22
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRefreshDemoUiConfig } from './demo-refresh-ui';
|
|
3
|
+
|
|
4
|
+
const apis = {
|
|
5
|
+
brainService: 'http://localhost:4000/trpc',
|
|
6
|
+
cloudService: 'http://localhost:4100/trpc',
|
|
7
|
+
lcaApi: 'http://localhost:5200/trpc',
|
|
8
|
+
notificationsEndpoint: 'http://localhost:5200/api/notifications/send',
|
|
9
|
+
};
|
|
10
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
11
|
+
const serve = (overrides = {}) => {
|
|
12
|
+
const fetch = vi
|
|
13
|
+
.fn()
|
|
14
|
+
.mockResolvedValue(new Response(JSON.stringify({ apis: { ...apis, ...overrides } })));
|
|
15
|
+
vi.stubGlobal('fetch', fetch);
|
|
16
|
+
return fetch;
|
|
17
|
+
};
|
|
18
|
+
describe('UI demo preflight', () => {
|
|
19
|
+
it('explains how to recover when the app is not running', async () => {
|
|
20
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
|
|
21
|
+
await expect(
|
|
22
|
+
getRefreshDemoUiConfig('http://localhost:3000', apis.brainService)
|
|
23
|
+
).rejects.toThrow('Start the LearnCard app in local development mode');
|
|
24
|
+
});
|
|
25
|
+
it('uses the running app’s actual cloud and notification ports', async () => {
|
|
26
|
+
const fetch = serve();
|
|
27
|
+
expect(
|
|
28
|
+
await getRefreshDemoUiConfig('http://localhost:3001', 'http://localhost:4000/trpc')
|
|
29
|
+
).toEqual({
|
|
30
|
+
appOrigin: 'http://localhost:3001',
|
|
31
|
+
cloud: apis.cloudService,
|
|
32
|
+
lcaApi: apis.lcaApi,
|
|
33
|
+
notificationsWebhook: apis.notificationsEndpoint,
|
|
34
|
+
});
|
|
35
|
+
expect(fetch.mock.calls[0]![0].href).toBe('http://localhost:3001/tenant-config.json');
|
|
36
|
+
expect(fetch.mock.calls[0]![1]!.redirect).toBe('error');
|
|
37
|
+
});
|
|
38
|
+
it.each([
|
|
39
|
+
'https://learncard.app',
|
|
40
|
+
'http://localhost.evil.example',
|
|
41
|
+
'http://user:pass@localhost:3000',
|
|
42
|
+
'file:///tmp/app',
|
|
43
|
+
])('rejects nonlocal or credential-bearing app URL %s before fetching', async app => {
|
|
44
|
+
const fetch = serve();
|
|
45
|
+
await expect(getRefreshDemoUiConfig(app, apis.brainService)).rejects.toThrow('loopback');
|
|
46
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
it.each([
|
|
49
|
+
{ brainService: 'http://localhost:9999/trpc' },
|
|
50
|
+
{ cloudService: 'https://cloud.learncard.com/trpc' },
|
|
51
|
+
{ notificationsEndpoint: 'https://api.learncard.app/api/notifications/send' },
|
|
52
|
+
{ notificationsEndpoint: 'http://localhost:5100/api/notifications/send' },
|
|
53
|
+
])('rejects mismatched or nonlocal services: %j', async overrides => {
|
|
54
|
+
serve(overrides);
|
|
55
|
+
await expect(
|
|
56
|
+
getRefreshDemoUiConfig('http://localhost:3000', apis.brainService)
|
|
57
|
+
).rejects.toThrow();
|
|
58
|
+
});
|
|
59
|
+
it('derives the webhook from the app API when no override is present', async () => {
|
|
60
|
+
serve({ notificationsEndpoint: undefined });
|
|
61
|
+
expect(
|
|
62
|
+
(await getRefreshDemoUiConfig('http://localhost:3000', apis.brainService))
|
|
63
|
+
.notificationsWebhook
|
|
64
|
+
).toBe(apis.notificationsEndpoint);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export const requireLoopbackUrl = (value: unknown, label: string): URL => {
|
|
2
|
+
try {
|
|
3
|
+
if (typeof value !== 'string') throw new Error();
|
|
4
|
+
const url = new URL(value);
|
|
5
|
+
if (
|
|
6
|
+
!['http:', 'https:'].includes(url.protocol) ||
|
|
7
|
+
!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) ||
|
|
8
|
+
url.username ||
|
|
9
|
+
url.password ||
|
|
10
|
+
url.search ||
|
|
11
|
+
url.hash
|
|
12
|
+
)
|
|
13
|
+
throw new Error();
|
|
14
|
+
return url;
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`${label} must be an HTTP(S) loopback URL without credentials, query, or fragment.`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Check the running app before creating accounts or showing a demo sign-in secret. */
|
|
23
|
+
export const getRefreshDemoUiConfig = async (
|
|
24
|
+
appUrl: string,
|
|
25
|
+
network: string
|
|
26
|
+
): Promise<{ appOrigin: string; cloud: string; lcaApi: string; notificationsWebhook: string }> => {
|
|
27
|
+
const app = requireLoopbackUrl(appUrl, '--app-url');
|
|
28
|
+
const backend = requireLoopbackUrl(network, '--network');
|
|
29
|
+
let config: { apis?: Record<string, unknown> } | null;
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetch(new URL('/tenant-config.json', app), {
|
|
32
|
+
redirect: 'error',
|
|
33
|
+
signal: AbortSignal.timeout(10_000),
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
36
|
+
config = (await response.json()) as { apis?: Record<string, unknown> } | null;
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'Could not read the local app configuration. Start the LearnCard app in local development mode and check --app-url.'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const configuredNetwork = requireLoopbackUrl(config?.apis?.brainService, 'App Brain service');
|
|
43
|
+
if (configuredNetwork.href.replace(/\/$/, '') !== backend.href.replace(/\/$/, '')) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'The app and CLI must use the same Brain service. Set --network to the app’s local Brain URL.'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const cloud = requireLoopbackUrl(config?.apis?.cloudService, 'App LearnCloud service');
|
|
49
|
+
const api = requireLoopbackUrl(config?.apis?.lcaApi, 'App notification API');
|
|
50
|
+
const notifications = requireLoopbackUrl(
|
|
51
|
+
config?.apis?.notificationsEndpoint ?? new URL('/api/notifications/send', api).href,
|
|
52
|
+
'App notifications endpoint'
|
|
53
|
+
);
|
|
54
|
+
if (notifications.origin !== api.origin) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
'The local app’s notification API and notifications endpoint must use the same origin.'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
appOrigin: app.origin,
|
|
61
|
+
cloud: cloud.href,
|
|
62
|
+
lcaApi: api.href,
|
|
63
|
+
notificationsWebhook: notifications.href,
|
|
64
|
+
};
|
|
65
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const mocks = vi.hoisted(() => ({
|
|
3
|
+
init: vi.fn(),
|
|
4
|
+
question: vi.fn(),
|
|
5
|
+
close: vi.fn(),
|
|
6
|
+
log: vi.fn(),
|
|
7
|
+
set: vi.fn(),
|
|
8
|
+
preflight: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('@learncard/init', () => ({ initLearnCard: mocks.init }));
|
|
11
|
+
vi.mock('@learncard/types', () => ({
|
|
12
|
+
VCValidator: {
|
|
13
|
+
parse: (v: unknown) => v,
|
|
14
|
+
safeParse: (v: unknown) => ({ success: true, data: v }),
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('node:readline/promises', () => ({
|
|
18
|
+
createInterface: () => ({ question: mocks.question, close: mocks.close }),
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('./out', () => ({ out: { log: mocks.log, set: mocks.set } }));
|
|
21
|
+
vi.mock('./project', () => ({ resolveServices: (_: unknown, network: string) => ({ network }) }));
|
|
22
|
+
vi.mock('./demo-refresh-ui', () => ({ getRefreshDemoUiConfig: mocks.preflight }));
|
|
23
|
+
import { runRefreshDemo } from './demo-refresh';
|
|
24
|
+
const oldTTY = process.stdin.isTTY;
|
|
25
|
+
const oldYes = process.env.LC_YES;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.resetAllMocks();
|
|
28
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
29
|
+
delete process.env.LC_YES;
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: oldTTY, configurable: true });
|
|
33
|
+
if (oldYes === undefined) delete process.env.LC_YES;
|
|
34
|
+
else process.env.LC_YES = oldYes;
|
|
35
|
+
});
|
|
36
|
+
describe('interactive frontend refresh demonstration', () => {
|
|
37
|
+
it.each([{ yes: true }, { json: true }])(
|
|
38
|
+
'rejects auto-advance flags before creating accounts: %j',
|
|
39
|
+
async flags => {
|
|
40
|
+
await expect(runRefreshDemo({ ui: true, ...flags })).rejects.toThrow(
|
|
41
|
+
'interactive terminal'
|
|
42
|
+
);
|
|
43
|
+
expect(mocks.init).not.toHaveBeenCalled();
|
|
44
|
+
expect(mocks.preflight).not.toHaveBeenCalled();
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
it('rejects piped input and LC_YES=1', async () => {
|
|
48
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
|
|
49
|
+
await expect(runRefreshDemo({ ui: true })).rejects.toThrow('interactive terminal');
|
|
50
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
51
|
+
process.env.LC_YES = '1';
|
|
52
|
+
await expect(runRefreshDemo({ ui: true })).rejects.toThrow('interactive terminal');
|
|
53
|
+
expect(mocks.init).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
it('waits for real app saves and never accepts or refreshes on the holder’s behalf', async () => {
|
|
56
|
+
mocks.preflight.mockResolvedValue({
|
|
57
|
+
appOrigin: 'http://localhost:3000',
|
|
58
|
+
cloud: 'http://localhost:4100/trpc',
|
|
59
|
+
notificationsWebhook: 'http://localhost:5200/api/notifications/send',
|
|
60
|
+
});
|
|
61
|
+
let prompts = 0;
|
|
62
|
+
mocks.question.mockImplementation(async () => {
|
|
63
|
+
prompts++;
|
|
64
|
+
return '';
|
|
65
|
+
});
|
|
66
|
+
const original = { id: 'urn:uuid:demo', name: 'Provisional Course Certificate' };
|
|
67
|
+
const final = { ...original, name: 'Final Course Certificate' };
|
|
68
|
+
const issuer = {
|
|
69
|
+
id: { did: () => 'did:key:issuer' },
|
|
70
|
+
invoke: {
|
|
71
|
+
createProfile: vi.fn(),
|
|
72
|
+
createBoost: vi.fn().mockResolvedValue('boost:demo'),
|
|
73
|
+
sendBoost: vi.fn().mockResolvedValue({
|
|
74
|
+
credentialUri: 'credential:demo',
|
|
75
|
+
refresh: {
|
|
76
|
+
credentialId: original.id,
|
|
77
|
+
refreshId: 'refresh:demo',
|
|
78
|
+
issuerDid: 'did:key:issuer',
|
|
79
|
+
holderDid: 'did:key:holder',
|
|
80
|
+
refreshService: { id: 'http://localhost:4000/refresh/demo' },
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
issueCredential: vi.fn(async v => v),
|
|
84
|
+
publishCredentialRefresh: vi.fn(async () => {
|
|
85
|
+
expect(prompts).toBe(4);
|
|
86
|
+
return { version: 2 };
|
|
87
|
+
}),
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const holder = {
|
|
91
|
+
invoke: {
|
|
92
|
+
createProfile: vi.fn(),
|
|
93
|
+
acceptCredential: vi.fn(),
|
|
94
|
+
refreshCredential: vi.fn(),
|
|
95
|
+
verifyCredential: vi
|
|
96
|
+
.fn()
|
|
97
|
+
.mockResolvedValue({ checks: ['proof'], errors: [], warnings: [] }),
|
|
98
|
+
},
|
|
99
|
+
index: {
|
|
100
|
+
LearnCloud: {
|
|
101
|
+
get: vi.fn(async () => (prompts < 4 ? [] : [{ uri: 'stored:demo' }])),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
read: {
|
|
105
|
+
get: vi.fn(async (uri: string) =>
|
|
106
|
+
uri === 'stored:demo' && prompts >= 6 ? final : original
|
|
107
|
+
),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
mocks.init.mockResolvedValueOnce(issuer).mockResolvedValueOnce(holder);
|
|
111
|
+
await runRefreshDemo({ ui: true });
|
|
112
|
+
// Full certificate views render the nested achievement, not the thumbnail title.
|
|
113
|
+
const sentAchievement =
|
|
114
|
+
issuer.invoke.createBoost.mock.calls[0]![0].credentialSubject.achievement;
|
|
115
|
+
const updatedAchievement =
|
|
116
|
+
issuer.invoke.issueCredential.mock.calls[0]![0].credentialSubject.achievement;
|
|
117
|
+
expect(sentAchievement.name).toContain('Provisional Results');
|
|
118
|
+
expect(sentAchievement.description).toContain('Final grade: Pending');
|
|
119
|
+
expect(sentAchievement.description).not.toContain('Course completed');
|
|
120
|
+
expect(updatedAchievement.name).toContain('Final Results');
|
|
121
|
+
expect(updatedAchievement.description).toContain('Final grade: A');
|
|
122
|
+
expect(updatedAchievement.id).toBe(sentAchievement.id);
|
|
123
|
+
expect(holder.invoke.acceptCredential).not.toHaveBeenCalled();
|
|
124
|
+
expect(holder.invoke.refreshCredential).not.toHaveBeenCalled();
|
|
125
|
+
expect(holder.invoke.verifyCredential).toHaveBeenCalledWith(final);
|
|
126
|
+
expect(holder.index.LearnCloud.get).toHaveBeenCalledTimes(4);
|
|
127
|
+
expect(holder.invoke.createProfile).toHaveBeenCalledWith(
|
|
128
|
+
expect.objectContaining({
|
|
129
|
+
notificationsWebhook: 'http://localhost:5200/api/notifications/send',
|
|
130
|
+
})
|
|
131
|
+
);
|
|
132
|
+
expect(mocks.log.mock.calls.flat().join('\n')).toMatch(
|
|
133
|
+
/http:\/\/localhost:3000\/developer\/sign-in\?next=%2Fpassport#seed=[0-9a-f]{64}/
|
|
134
|
+
);
|
|
135
|
+
expect(mocks.set).toHaveBeenCalledWith(
|
|
136
|
+
expect.objectContaining({ ui: true, status: 'updated', sameCredentialId: true })
|
|
137
|
+
);
|
|
138
|
+
expect(mocks.close).toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { initLearnCard } from '@learncard/init';
|
|
4
|
+
import { VCValidator, type UnsignedVC } from '@learncard/types';
|
|
5
|
+
import { generateRandomSeed } from './random';
|
|
6
|
+
import { out } from './out';
|
|
7
|
+
import { resolveServices } from './project';
|
|
8
|
+
import { getRefreshDemoUiConfig } from './demo-refresh-ui';
|
|
9
|
+
|
|
10
|
+
export interface RefreshDemoOptions {
|
|
11
|
+
network?: string;
|
|
12
|
+
yes?: boolean;
|
|
13
|
+
json?: boolean;
|
|
14
|
+
didkit?: Promise<Buffer>;
|
|
15
|
+
ui?: boolean;
|
|
16
|
+
appUrl?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const LOCAL_NETWORK = 'http://localhost:4000/trpc';
|
|
20
|
+
const BEFORE = 'Provisional Course Certificate';
|
|
21
|
+
const AFTER = 'Final Course Certificate';
|
|
22
|
+
|
|
23
|
+
/** A real sendBoost lifecycle, narrated for a presenter with no SDK setup required. */
|
|
24
|
+
export const runRefreshDemo = async (options: RefreshDemoOptions): Promise<void> => {
|
|
25
|
+
// Use fresh demo identities, independent of the presenter's .env and account.
|
|
26
|
+
const { network } = resolveServices({}, options.network || LOCAL_NETWORK, {});
|
|
27
|
+
const networkUrl = new URL(network);
|
|
28
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(networkUrl.hostname);
|
|
29
|
+
const interactive =
|
|
30
|
+
!options.yes && !options.json && !!process.stdin.isTTY && process.env.LC_YES !== '1';
|
|
31
|
+
if (options.ui && !interactive) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
'--ui requires an interactive terminal; omit --yes, --json, and LC_YES=1 so you can claim and refresh in the app.'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (options.appUrl && !options.ui) throw new Error('--app-url requires --ui.');
|
|
37
|
+
const ui = options.ui
|
|
38
|
+
? await getRefreshDemoUiConfig(options.appUrl ?? 'http://localhost:3000', network)
|
|
39
|
+
: undefined;
|
|
40
|
+
const prompts = interactive
|
|
41
|
+
? createInterface({ input: process.stdin, output: process.stdout })
|
|
42
|
+
: undefined;
|
|
43
|
+
const pause = async (next: string): Promise<void> => {
|
|
44
|
+
if (prompts) await prompts.question(`\nPress Enter to ${next}... `);
|
|
45
|
+
};
|
|
46
|
+
let step = 'connect to the demo network';
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
out.log('\nLearnCard: watch a credential refresh\n');
|
|
50
|
+
out.log(`Network: ${network}`);
|
|
51
|
+
out.log('This creates two demo accounts and a badge on this network.');
|
|
52
|
+
out.log(
|
|
53
|
+
ui
|
|
54
|
+
? 'The recipient signs into the local app; keep this terminal open until the demo finishes.'
|
|
55
|
+
: 'Account keys stay in this session; rerunning creates a fresh demonstration.'
|
|
56
|
+
);
|
|
57
|
+
await pause('start');
|
|
58
|
+
out.log('\nSetting up the issuer and recipient...');
|
|
59
|
+
|
|
60
|
+
// Trust the explicitly selected demo network for Boost verification.
|
|
61
|
+
const config = {
|
|
62
|
+
network,
|
|
63
|
+
...(ui && { cloud: { url: ui.cloud } }),
|
|
64
|
+
...(options.didkit && { didkit: options.didkit }),
|
|
65
|
+
trustedBoostRegistry: `data:application/json,${encodeURIComponent(
|
|
66
|
+
JSON.stringify([
|
|
67
|
+
{
|
|
68
|
+
id: 'Refresh demo network',
|
|
69
|
+
url: networkUrl.origin,
|
|
70
|
+
did: `did:web:${encodeURIComponent(networkUrl.host)}`,
|
|
71
|
+
},
|
|
72
|
+
])
|
|
73
|
+
)}`,
|
|
74
|
+
};
|
|
75
|
+
const issuer = await initLearnCard({ ...config, seed: generateRandomSeed(), network });
|
|
76
|
+
const holderSeed = generateRandomSeed();
|
|
77
|
+
const holder = await initLearnCard({ ...config, seed: holderSeed, network });
|
|
78
|
+
const suffix = randomUUID().slice(0, 8);
|
|
79
|
+
const recipientProfileId = `refresh-learner-${suffix}`;
|
|
80
|
+
await issuer.invoke.createProfile({
|
|
81
|
+
profileId: `refresh-issuer-${suffix}`,
|
|
82
|
+
displayName: 'Refresh Demo School',
|
|
83
|
+
bio: '',
|
|
84
|
+
shortBio: '',
|
|
85
|
+
});
|
|
86
|
+
await holder.invoke.createProfile({
|
|
87
|
+
profileId: recipientProfileId,
|
|
88
|
+
displayName: 'Refresh Demo Learner',
|
|
89
|
+
bio: '',
|
|
90
|
+
shortBio: '',
|
|
91
|
+
...(ui && { notificationsWebhook: ui.notificationsWebhook, locale: 'en' }),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const template = {
|
|
95
|
+
'@context': [
|
|
96
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
97
|
+
'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json',
|
|
98
|
+
'https://ctx.learncard.com/boosts/1.0.1.json',
|
|
99
|
+
],
|
|
100
|
+
type: ['VerifiableCredential', 'OpenBadgeCredential', 'BoostCredential'],
|
|
101
|
+
name: BEFORE,
|
|
102
|
+
issuer: issuer.id.did(),
|
|
103
|
+
credentialSubject: {
|
|
104
|
+
type: ['AchievementSubject'],
|
|
105
|
+
achievement: {
|
|
106
|
+
id: `urn:uuid:${randomUUID()}`,
|
|
107
|
+
type: ['Achievement'],
|
|
108
|
+
name: 'Introduction to Biology — Provisional Results',
|
|
109
|
+
description:
|
|
110
|
+
'Coursework submitted. Final grade: Pending. Results await review.',
|
|
111
|
+
criteria: {
|
|
112
|
+
narrative:
|
|
113
|
+
'Final results require review of coursework and the final assessment.',
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
} satisfies UnsignedVC;
|
|
118
|
+
|
|
119
|
+
if (ui) {
|
|
120
|
+
const login = new URL('/developer/sign-in', ui.appOrigin);
|
|
121
|
+
login.searchParams.set('next', '/passport');
|
|
122
|
+
login.hash = `seed=${holderSeed}`;
|
|
123
|
+
out.log(`\nOpen this link to sign in as Refresh Demo Learner:\n${login.href}`);
|
|
124
|
+
out.log(
|
|
125
|
+
'This link controls a disposable demo account. Keep it private and use it only for test data.'
|
|
126
|
+
);
|
|
127
|
+
out.log('If already signed in, choose the demo account switch. Keep the app open.');
|
|
128
|
+
await pause('send the certificate after signing in');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
step = 'send the refreshable badge';
|
|
132
|
+
out.log('\n1 / 3 SEND A BADGE');
|
|
133
|
+
out.log('Sending through sendBoost with refresh enabled...');
|
|
134
|
+
const boostUri = await issuer.invoke.createBoost(template, { category: 'Achievement' });
|
|
135
|
+
const sent = await issuer.invoke.sendBoost(recipientProfileId, boostUri, {
|
|
136
|
+
enableRefresh: true,
|
|
137
|
+
});
|
|
138
|
+
if (!sent?.refresh) throw new Error('The SDK did not return a refresh receipt.');
|
|
139
|
+
const { credentialUri, refresh } = sent;
|
|
140
|
+
if (!ui && !(await holder.invoke.acceptCredential(credentialUri))) {
|
|
141
|
+
throw new Error('The recipient could not accept the badge.');
|
|
142
|
+
}
|
|
143
|
+
const original = VCValidator.parse(await holder.read.get(credentialUri));
|
|
144
|
+
if (original.name !== BEFORE) throw new Error('The delivered badge did not match.');
|
|
145
|
+
out.log(`Recipient sees: "${original.name}"`);
|
|
146
|
+
|
|
147
|
+
const readAppCredential = async () => {
|
|
148
|
+
const records = await holder.index.LearnCloud.get({});
|
|
149
|
+
for (const record of records ?? []) {
|
|
150
|
+
const parsed = VCValidator.safeParse(await holder.read.get(record.uri));
|
|
151
|
+
if (parsed.success && parsed.data.id === refresh.credentialId) return parsed.data;
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (ui) {
|
|
157
|
+
out.log('\nIn the app: reload if needed, open Alerts, then Claim → Accept.');
|
|
158
|
+
out.log('Find Provisional Course Certificate under Passport → Achievements.');
|
|
159
|
+
out.log(
|
|
160
|
+
'Open it: the full certificate shows Provisional Results and Final grade: Pending.'
|
|
161
|
+
);
|
|
162
|
+
let claimed = false;
|
|
163
|
+
do {
|
|
164
|
+
await pause('confirm the certificate is saved in the app and publish its update');
|
|
165
|
+
claimed = Boolean(await readAppCredential());
|
|
166
|
+
if (!claimed) {
|
|
167
|
+
out.log(
|
|
168
|
+
'The certificate is not saved in the demo account yet. Finish Claim → Accept in the app first.'
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
} while (!claimed);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!ui) await pause('publish the final certificate');
|
|
175
|
+
step = 'publish the updated certificate';
|
|
176
|
+
out.log('\n2 / 3 PUBLISH AN UPDATE');
|
|
177
|
+
out.log('The school finalizes the certificate...');
|
|
178
|
+
// Rebuild solely from the issuer's own claims and the sendBoost receipt.
|
|
179
|
+
// The issuer cannot read the credential encrypted for the recipient.
|
|
180
|
+
const updated = await issuer.invoke.issueCredential({
|
|
181
|
+
...template,
|
|
182
|
+
name: AFTER,
|
|
183
|
+
id: refresh.credentialId,
|
|
184
|
+
issuer: refresh.issuerDid,
|
|
185
|
+
boostId: boostUri,
|
|
186
|
+
validFrom: new Date().toISOString(),
|
|
187
|
+
refreshService: refresh.refreshService,
|
|
188
|
+
...(refresh.credentialStatus && { credentialStatus: refresh.credentialStatus }),
|
|
189
|
+
credentialSubject: {
|
|
190
|
+
...template.credentialSubject,
|
|
191
|
+
id: refresh.holderDid,
|
|
192
|
+
achievement: {
|
|
193
|
+
...template.credentialSubject.achievement,
|
|
194
|
+
name: 'Introduction to Biology — Final Results',
|
|
195
|
+
description:
|
|
196
|
+
'Course completed. Final grade: A. Coursework and final assessment reviewed.',
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
const published = await issuer.invoke.publishCredentialRefresh({
|
|
201
|
+
mode: 'issuer-signed',
|
|
202
|
+
refreshId: refresh.refreshId,
|
|
203
|
+
signedCredential: updated,
|
|
204
|
+
updateSummary: 'Final results are ready. Final grade: A.',
|
|
205
|
+
idempotencyKey: `demo-final-${suffix}`,
|
|
206
|
+
});
|
|
207
|
+
if (published.version !== 2) throw new Error('Expected to publish version 2.');
|
|
208
|
+
out.log('Version 2 is available. No second badge was sent.');
|
|
209
|
+
out.log(`Recipient's existing copy still says: "${original.name}"`);
|
|
210
|
+
|
|
211
|
+
if (ui) {
|
|
212
|
+
step = 'confirm the app refreshed the certificate';
|
|
213
|
+
out.log('\n3 / 3 REFRESH IN THE APP');
|
|
214
|
+
out.log(
|
|
215
|
+
'Reload the app, open Alerts, and select “Refresh Demo School updated one of your credentials.”'
|
|
216
|
+
);
|
|
217
|
+
out.log(
|
|
218
|
+
'The app should open Final Course Certificate. Its existing Passport entry is updated.'
|
|
219
|
+
);
|
|
220
|
+
out.log('The full certificate now shows Final Results and Final grade: A.');
|
|
221
|
+
let appUpdated = false;
|
|
222
|
+
do {
|
|
223
|
+
await pause('confirm the final certificate is visible');
|
|
224
|
+
const current = await readAppCredential();
|
|
225
|
+
if (current?.name !== AFTER) {
|
|
226
|
+
out.log(
|
|
227
|
+
'The app still has the original copy. Tap the update notification and wait for the final certificate.'
|
|
228
|
+
);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const check = await holder.invoke.verifyCredential(current);
|
|
232
|
+
if (
|
|
233
|
+
check.errors.length ||
|
|
234
|
+
check.warnings.length ||
|
|
235
|
+
!check.checks.includes('proof')
|
|
236
|
+
) {
|
|
237
|
+
throw new Error('The app’s updated certificate did not pass verification.');
|
|
238
|
+
}
|
|
239
|
+
appUpdated = true;
|
|
240
|
+
} while (!appUpdated);
|
|
241
|
+
out.log(
|
|
242
|
+
'\nVerified: the app saved the final certificate under the same credential identity. Sent once.'
|
|
243
|
+
);
|
|
244
|
+
out.log(
|
|
245
|
+
'You can close this terminal; the recipient stays signed in. Rerun --ui for a fresh demo.'
|
|
246
|
+
);
|
|
247
|
+
out.set({
|
|
248
|
+
network,
|
|
249
|
+
credentialUri,
|
|
250
|
+
refreshId: refresh.refreshId,
|
|
251
|
+
before: BEFORE,
|
|
252
|
+
after: AFTER,
|
|
253
|
+
version: published.version,
|
|
254
|
+
status: 'updated',
|
|
255
|
+
sameCredentialId: true,
|
|
256
|
+
ui: true,
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
await pause('refresh the recipient’s copy');
|
|
262
|
+
step = 'refresh the recipient’s copy';
|
|
263
|
+
out.log('\n3 / 3 REFRESH THE RECIPIENT’S COPY');
|
|
264
|
+
out.log('Checking for an update and verifying the new credential...');
|
|
265
|
+
// Local HTTP is allowed only for this generated credential on the selected
|
|
266
|
+
// loopback origin. Hosted demos retain the SDK's default transport guards.
|
|
267
|
+
if (local && new URL(refresh.refreshService.id).origin !== networkUrl.origin) {
|
|
268
|
+
throw new Error('The refresh service does not match the selected local network.');
|
|
269
|
+
}
|
|
270
|
+
const refreshed = await holder.invoke.refreshCredential(
|
|
271
|
+
original,
|
|
272
|
+
local
|
|
273
|
+
? { allowInsecureHttp: true, allowPrivateAddresses: true, maxRedirects: 0 }
|
|
274
|
+
: undefined
|
|
275
|
+
);
|
|
276
|
+
if (refreshed.status !== 'updated') {
|
|
277
|
+
throw new Error(`Refresh did not return an update (${refreshed.status}).`);
|
|
278
|
+
}
|
|
279
|
+
if (refreshed.credential.name !== AFTER || refreshed.credential.id !== original.id) {
|
|
280
|
+
throw new Error('The refreshed certificate did not match the published update.');
|
|
281
|
+
}
|
|
282
|
+
out.log(`\nBefore: "${original.name}"`);
|
|
283
|
+
out.log(`After: "${refreshed.credential.name}"`);
|
|
284
|
+
out.log('Verified update. Same credential identity. Sent once.');
|
|
285
|
+
out.log(
|
|
286
|
+
'The updated copy is shown in this session; this demo does not save it in the app.'
|
|
287
|
+
);
|
|
288
|
+
out.set({
|
|
289
|
+
network,
|
|
290
|
+
credentialUri,
|
|
291
|
+
refreshId: refresh.refreshId,
|
|
292
|
+
before: original.name,
|
|
293
|
+
after: refreshed.credential.name,
|
|
294
|
+
version: published.version,
|
|
295
|
+
status: refreshed.status,
|
|
296
|
+
sameCredentialId: true,
|
|
297
|
+
});
|
|
298
|
+
} catch (error) {
|
|
299
|
+
const detail = error instanceof Error ? error.message.split('\n')[0] : 'Please try again.';
|
|
300
|
+
throw Object.assign(
|
|
301
|
+
new Error(
|
|
302
|
+
`Could not ${step}: ${detail} Use a running network with managed refresh and LC-2198 deployed.`
|
|
303
|
+
),
|
|
304
|
+
{ cause: error }
|
|
305
|
+
);
|
|
306
|
+
} finally {
|
|
307
|
+
prompts?.close();
|
|
308
|
+
}
|
|
309
|
+
};
|