@learncard/cli 3.4.17 → 3.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +10 -0
- package/dist/index.js +1933 -56
- package/package.json +24 -24
- package/rollup.config.js +25 -3
- package/scripts/embed-snippets.ts +25 -0
- package/src/consent-contract.ts +116 -0
- package/src/embed.ts +102 -0
- package/src/index.tsx +473 -147
- package/src/init.ts +26 -0
- package/src/open.test.ts +40 -0
- package/src/open.ts +186 -0
- package/src/out.test.ts +40 -0
- package/src/out.ts +16 -0
- package/src/phase-two.test.ts +154 -0
- package/src/project.test.ts +207 -0
- package/src/project.ts +394 -0
- package/src/revoke.test.ts +29 -0
- package/src/revoke.ts +65 -0
- package/src/send-template.test.ts +20 -0
- package/src/send.test.ts +45 -0
- package/src/send.ts +245 -0
- package/src/setup-signing.test.ts +62 -0
- package/src/setup-signing.ts +185 -0
- package/src/snippet-files.ts +16 -0
- package/src/status.test.ts +33 -0
- package/src/status.ts +96 -0
- package/src/token.test.ts +28 -0
- package/src/token.ts +138 -0
- package/src/verify.test.ts +35 -0
- package/src/verify.ts +66 -0
- package/src/webhook.ts +247 -0
- package/tsconfig.json +1 -0
package/src/send.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import {
|
|
5
|
+
connect,
|
|
6
|
+
createPrompts,
|
|
7
|
+
ensureIdentity,
|
|
8
|
+
ensureProfile,
|
|
9
|
+
loadProject,
|
|
10
|
+
saveProject,
|
|
11
|
+
type ProjectOptions,
|
|
12
|
+
localizeSnippet,
|
|
13
|
+
resolveServices,
|
|
14
|
+
} from './project';
|
|
15
|
+
import { setupSigning } from './setup-signing';
|
|
16
|
+
import { SEND_MJS, SEND_FROM_TEMPLATE_MJS } from './generated/snippets';
|
|
17
|
+
import { out } from './out';
|
|
18
|
+
|
|
19
|
+
export { SEND_MJS } from './generated/snippets';
|
|
20
|
+
export { parseEnv, upsertEnv, toProfileId } from './project';
|
|
21
|
+
export type SendEnv = { SECURE_SEED?: string; PROFILE_ID?: string };
|
|
22
|
+
export type Badge = { name: string; description: string };
|
|
23
|
+
export const DEFAULT_BADGE: Badge = {
|
|
24
|
+
name: 'Quickstart Complete',
|
|
25
|
+
description: 'Sent a verifiable credential with LearnCard.',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const quickstartCredential = (issuerDid: string, badge: Badge = DEFAULT_BADGE) => ({
|
|
29
|
+
'@context': [
|
|
30
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
31
|
+
'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json',
|
|
32
|
+
],
|
|
33
|
+
type: ['VerifiableCredential', 'OpenBadgeCredential'],
|
|
34
|
+
issuer: issuerDid,
|
|
35
|
+
validFrom: new Date().toISOString(),
|
|
36
|
+
name: badge.name,
|
|
37
|
+
credentialSubject: {
|
|
38
|
+
type: ['AchievementSubject'],
|
|
39
|
+
achievement: {
|
|
40
|
+
id: `urn:uuid:${randomUUID()}`,
|
|
41
|
+
type: ['Achievement'],
|
|
42
|
+
name: badge.name,
|
|
43
|
+
description: badge.description,
|
|
44
|
+
criteria: { narrative: 'Ran the LearnCard quickstart.' },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const templateCredential = (issuerDid: string, badge: Badge = DEFAULT_BADGE) => {
|
|
50
|
+
const credential = quickstartCredential(issuerDid, badge);
|
|
51
|
+
credential['@context'].push('https://ctx.learncard.com/boosts/1.0.1.json');
|
|
52
|
+
credential.type.push('BoostCredential');
|
|
53
|
+
return credential;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type SendOptions = ProjectOptions & {
|
|
57
|
+
badge?: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
template?: boolean;
|
|
60
|
+
templateUri?: string;
|
|
61
|
+
webhookUrl?: string;
|
|
62
|
+
suppressDelivery?: boolean;
|
|
63
|
+
guardianEmail?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type SendDeliveryOptions = {
|
|
67
|
+
webhookUrl?: string;
|
|
68
|
+
suppressDelivery?: boolean;
|
|
69
|
+
guardianEmail?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const sendOptions = (options: SendOptions): { options?: SendDeliveryOptions } => {
|
|
73
|
+
const picked: SendDeliveryOptions = {
|
|
74
|
+
webhookUrl: options.webhookUrl,
|
|
75
|
+
suppressDelivery: options.suppressDelivery,
|
|
76
|
+
guardianEmail: options.guardianEmail,
|
|
77
|
+
};
|
|
78
|
+
return Object.values(picked).some(v => v !== undefined) ? { options: picked } : {};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Inserts the effective send() delivery options right after `anchor` so a re-run of the
|
|
82
|
+
* generated script reproduces the same call. */
|
|
83
|
+
const withDeliveryOptions = (
|
|
84
|
+
content: string,
|
|
85
|
+
anchor: string,
|
|
86
|
+
deliveryOptions?: SendDeliveryOptions
|
|
87
|
+
): string =>
|
|
88
|
+
deliveryOptions
|
|
89
|
+
? content.replace(
|
|
90
|
+
anchor,
|
|
91
|
+
() => `${anchor}\n options: ${JSON.stringify(deliveryOptions)},`
|
|
92
|
+
)
|
|
93
|
+
: content;
|
|
94
|
+
|
|
95
|
+
/** Substitute the user's choices into the template that the docs snippet uses. */
|
|
96
|
+
export const personalizeSendMjs = (
|
|
97
|
+
displayName: string,
|
|
98
|
+
badge: Badge,
|
|
99
|
+
deliveryOptions?: SendDeliveryOptions
|
|
100
|
+
): string =>
|
|
101
|
+
withDeliveryOptions(
|
|
102
|
+
SEND_MJS.replace(
|
|
103
|
+
"displayName: 'My Organization'",
|
|
104
|
+
() => `displayName: ${JSON.stringify(displayName)}`
|
|
105
|
+
)
|
|
106
|
+
.split("'Quickstart Complete'")
|
|
107
|
+
.join(JSON.stringify(badge.name))
|
|
108
|
+
.replace("'Sent a verifiable credential with LearnCard.'", () =>
|
|
109
|
+
JSON.stringify(badge.description)
|
|
110
|
+
),
|
|
111
|
+
' signedCredential: credential,',
|
|
112
|
+
deliveryOptions
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
/** Substitute the effective send() delivery options into the from-template script. */
|
|
116
|
+
export const personalizeSendFromTemplateMjs = (deliveryOptions?: SendDeliveryOptions): string =>
|
|
117
|
+
withDeliveryOptions(
|
|
118
|
+
SEND_FROM_TEMPLATE_MJS,
|
|
119
|
+
' templateUri: process.env.TEMPLATE_URI,',
|
|
120
|
+
deliveryOptions
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
124
|
+
const PHONE = /^\+?\d{10,15}$/;
|
|
125
|
+
|
|
126
|
+
export const runSend = async (recipientEmail: string, options: SendOptions): Promise<void> => {
|
|
127
|
+
if (!EMAIL.test(recipientEmail) && !PHONE.test(recipientEmail))
|
|
128
|
+
throw new Error(
|
|
129
|
+
`"${recipientEmail}" is not an email address or phone number. Example: npx @learncard/cli send you@example.com`
|
|
130
|
+
);
|
|
131
|
+
const cwd = process.cwd();
|
|
132
|
+
const project = await loadProject(cwd);
|
|
133
|
+
const prompts = createPrompts(options.yes);
|
|
134
|
+
let displayName: string | undefined;
|
|
135
|
+
let badge: Badge;
|
|
136
|
+
try {
|
|
137
|
+
const needsName = !project.env.PROFILE_ID && !options.profileId && !options.name;
|
|
138
|
+
displayName = needsName
|
|
139
|
+
? await prompts.ask('Display name for your issuer profile', 'My Organization')
|
|
140
|
+
: options.name;
|
|
141
|
+
badge = {
|
|
142
|
+
name: options.badge ?? (await prompts.ask('Badge name', DEFAULT_BADGE.name)),
|
|
143
|
+
description: options.description ?? DEFAULT_BADGE.description,
|
|
144
|
+
};
|
|
145
|
+
} finally {
|
|
146
|
+
prompts.close();
|
|
147
|
+
}
|
|
148
|
+
const identity = await ensureIdentity(project, { ...options, name: displayName, yes: true });
|
|
149
|
+
const useTemplate = options.template || !!options.templateUri;
|
|
150
|
+
const learnCard = useTemplate
|
|
151
|
+
? await connect(project, { ...options, lca: true })
|
|
152
|
+
: await connect(project, options);
|
|
153
|
+
await ensureProfile(learnCard, identity, project);
|
|
154
|
+
|
|
155
|
+
const effectiveSendOptions = sendOptions(options);
|
|
156
|
+
let result;
|
|
157
|
+
if (useTemplate) {
|
|
158
|
+
// This branch connected with the LCA plugin; setupSigning accepts its required methods.
|
|
159
|
+
await setupSigning(
|
|
160
|
+
project,
|
|
161
|
+
learnCard as Awaited<
|
|
162
|
+
ReturnType<typeof import('@learncard/lca-api-plugin').initLCALearnCard>
|
|
163
|
+
>
|
|
164
|
+
);
|
|
165
|
+
if (options.templateUri) {
|
|
166
|
+
out.log(`Sending from template ${options.templateUri}.`);
|
|
167
|
+
if (!project.env.TEMPLATE_URI) {
|
|
168
|
+
await saveProject(project, { TEMPLATE_URI: options.templateUri });
|
|
169
|
+
}
|
|
170
|
+
} else if (!project.env.TEMPLATE_URI) {
|
|
171
|
+
const uri = await learnCard.invoke.createBoost(
|
|
172
|
+
templateCredential(learnCard.id.did(), badge),
|
|
173
|
+
{
|
|
174
|
+
name: badge.name,
|
|
175
|
+
category: 'Achievement',
|
|
176
|
+
status: 'LIVE',
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
await saveProject(project, { TEMPLATE_URI: uri });
|
|
180
|
+
} else {
|
|
181
|
+
out.log(
|
|
182
|
+
`Reusing template ${project.env.TEMPLATE_URI}; its saved badge name and description are unchanged.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
result = await learnCard.invoke.send({
|
|
186
|
+
type: 'boost',
|
|
187
|
+
recipient: recipientEmail,
|
|
188
|
+
templateUri: options.templateUri ?? project.env.TEMPLATE_URI!,
|
|
189
|
+
...effectiveSendOptions,
|
|
190
|
+
});
|
|
191
|
+
} else {
|
|
192
|
+
const credential = await learnCard.invoke.issueCredential(
|
|
193
|
+
quickstartCredential(learnCard.id.did(), badge)
|
|
194
|
+
);
|
|
195
|
+
result = await learnCard.invoke.send({
|
|
196
|
+
type: 'boost',
|
|
197
|
+
recipient: recipientEmail,
|
|
198
|
+
signedCredential: credential,
|
|
199
|
+
...effectiveSendOptions,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
out.log('');
|
|
203
|
+
if (result.inbox?.status === 'PENDING') {
|
|
204
|
+
out.log(
|
|
205
|
+
`Sent. ${recipientEmail} will get a claim email. You can also share this link directly:\n${result.inbox.claimUrl}`
|
|
206
|
+
);
|
|
207
|
+
} else {
|
|
208
|
+
out.log(
|
|
209
|
+
`Delivered. ${recipientEmail} already uses LearnCard — the credential is in their wallet.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
out.log(`Reusable template for this badge: ${result.uri}`);
|
|
213
|
+
const filename = useTemplate ? 'send-from-template.mjs' : 'send.mjs';
|
|
214
|
+
const sendPath = path.join(cwd, filename);
|
|
215
|
+
let wroteSendFile = false;
|
|
216
|
+
if (!(await fs.stat(sendPath).catch(() => null))) {
|
|
217
|
+
await fs.writeFile(
|
|
218
|
+
sendPath,
|
|
219
|
+
localizeSnippet(
|
|
220
|
+
useTemplate
|
|
221
|
+
? personalizeSendFromTemplateMjs(effectiveSendOptions.options)
|
|
222
|
+
: personalizeSendMjs(identity.displayName, badge, effectiveSendOptions.options),
|
|
223
|
+
resolveServices(project.env, options.network)
|
|
224
|
+
)
|
|
225
|
+
);
|
|
226
|
+
wroteSendFile = true;
|
|
227
|
+
out.log(
|
|
228
|
+
`\nThe code that just ran is in ./${filename} — run it yourself:\n npm install @learncard/init\n node --env-file=.env ${filename} ${recipientEmail}`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
out.log(`Check whether it was claimed: npx @learncard/cli status ${result.activityId}`);
|
|
232
|
+
out.log(`See it in the app: npx @learncard/cli open${options.template ? ' template' : ''}`);
|
|
233
|
+
out.set({
|
|
234
|
+
profileId: identity.profileId,
|
|
235
|
+
did: learnCard.id.did(),
|
|
236
|
+
recipient: recipientEmail,
|
|
237
|
+
status: result.inbox?.status === 'PENDING' ? 'PENDING' : 'ISSUED',
|
|
238
|
+
...(result.inbox?.claimUrl && { claimUrl: result.inbox.claimUrl }),
|
|
239
|
+
templateUri: result.uri,
|
|
240
|
+
activityId: result.activityId,
|
|
241
|
+
...(result.credentialUri && { credentialUri: result.credentialUri }),
|
|
242
|
+
...(result.inbox?.issuanceId && { issuanceId: result.inbox.issuanceId }),
|
|
243
|
+
files: wroteSendFile ? [`./${filename}`] : [],
|
|
244
|
+
});
|
|
245
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { authorityName, setupSigning } from './setup-signing';
|
|
6
|
+
import { loadProject } from './project';
|
|
7
|
+
|
|
8
|
+
describe('signing authority setup', () => {
|
|
9
|
+
it('defaults to default-issuer and reuses the project selection', () => {
|
|
10
|
+
const project = { env: {}, existing: '', envPath: '/unused/.env' };
|
|
11
|
+
expect(authorityName(project)).toBe('default-issuer');
|
|
12
|
+
expect(authorityName({ ...project, env: { SIGNING_AUTHORITY_NAME: 'existing' } })).toBe(
|
|
13
|
+
'existing'
|
|
14
|
+
);
|
|
15
|
+
expect(authorityName(project, 'custom')).toBe('custom');
|
|
16
|
+
});
|
|
17
|
+
it('resumes after failed registration and leaves an existing primary untouched', async () => {
|
|
18
|
+
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'lc-signing-'));
|
|
19
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
20
|
+
const authority = {
|
|
21
|
+
name: 'default-issuer',
|
|
22
|
+
endpoint: 'https://issuer.example/api',
|
|
23
|
+
did: 'did:key:issuer',
|
|
24
|
+
ownerDid: 'did:key:owner',
|
|
25
|
+
};
|
|
26
|
+
const invoke = {
|
|
27
|
+
getRegisteredSigningAuthorities: vi.fn().mockResolvedValue([]),
|
|
28
|
+
getSigningAuthorities: vi.fn().mockResolvedValueOnce([]).mockResolvedValue([authority]),
|
|
29
|
+
createSigningAuthority: vi.fn().mockResolvedValue(authority),
|
|
30
|
+
registerSigningAuthority: vi
|
|
31
|
+
.fn()
|
|
32
|
+
.mockRejectedValueOnce(new Error('Connection lost'))
|
|
33
|
+
.mockResolvedValue(true),
|
|
34
|
+
setPrimaryRegisteredSigningAuthority: vi.fn().mockResolvedValue(true),
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
const project = await loadProject(cwd);
|
|
38
|
+
await expect(setupSigning(project, { invoke })).rejects.toThrow('Connection lost');
|
|
39
|
+
await setupSigning(project, { invoke });
|
|
40
|
+
expect(invoke.createSigningAuthority).toHaveBeenCalledTimes(1);
|
|
41
|
+
expect(project.env.SIGNING_AUTHORITY_ENDPOINT).toBe(authority.endpoint);
|
|
42
|
+
invoke.getRegisteredSigningAuthorities.mockResolvedValue([
|
|
43
|
+
{
|
|
44
|
+
signingAuthority: { endpoint: authority.endpoint },
|
|
45
|
+
relationship: { name: authority.name, did: authority.did, isPrimary: true },
|
|
46
|
+
},
|
|
47
|
+
]);
|
|
48
|
+
await setupSigning(project, { invoke });
|
|
49
|
+
expect(invoke.createSigningAuthority).toHaveBeenCalledTimes(1);
|
|
50
|
+
expect(invoke.setPrimaryRegisteredSigningAuthority).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(log).toHaveBeenCalledWith(
|
|
52
|
+
'Signing authority "default-issuer" is already your primary.'
|
|
53
|
+
);
|
|
54
|
+
const demoProject = { env: {}, existing: '', envPath: '/unused/.env' };
|
|
55
|
+
await setupSigning(demoProject, { invoke }, undefined, { persist: false });
|
|
56
|
+
expect(demoProject.env).toEqual({});
|
|
57
|
+
} finally {
|
|
58
|
+
log.mockRestore();
|
|
59
|
+
await fs.rm(cwd, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { LCALearnCard } from '@learncard/lca-api-plugin';
|
|
2
|
+
import {
|
|
3
|
+
connect,
|
|
4
|
+
ensureIdentity,
|
|
5
|
+
ensureProfile,
|
|
6
|
+
loadProject,
|
|
7
|
+
saveProject,
|
|
8
|
+
type Project,
|
|
9
|
+
type ProjectOptions,
|
|
10
|
+
type NetworkCard,
|
|
11
|
+
} from './project';
|
|
12
|
+
import { out } from './out';
|
|
13
|
+
|
|
14
|
+
export const authorityName = (project: Project, name?: string): string =>
|
|
15
|
+
name ?? project.env.SIGNING_AUTHORITY_NAME ?? 'default-issuer';
|
|
16
|
+
|
|
17
|
+
export interface SigningAuthorityResult {
|
|
18
|
+
name: string;
|
|
19
|
+
endpoint: string;
|
|
20
|
+
did: string;
|
|
21
|
+
alreadyConfigured: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type SigningCard = {
|
|
25
|
+
invoke: Pick<
|
|
26
|
+
LCALearnCard['invoke'],
|
|
27
|
+
| 'getRegisteredSigningAuthorities'
|
|
28
|
+
| 'getSigningAuthorities'
|
|
29
|
+
| 'setPrimaryRegisteredSigningAuthority'
|
|
30
|
+
| 'createSigningAuthority'
|
|
31
|
+
| 'registerSigningAuthority'
|
|
32
|
+
>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Create and register a hosted signer once; also repair a changed primary selection. */
|
|
36
|
+
export const setupSigning = async (
|
|
37
|
+
project: Project,
|
|
38
|
+
learnCard: SigningCard,
|
|
39
|
+
requestedName?: string,
|
|
40
|
+
options: { persist?: boolean } = {}
|
|
41
|
+
): Promise<SigningAuthorityResult> => {
|
|
42
|
+
const name = authorityName(project, requestedName);
|
|
43
|
+
const registered = (await learnCard.invoke.getRegisteredSigningAuthorities()).find(
|
|
44
|
+
authority =>
|
|
45
|
+
authority.relationship.name === name &&
|
|
46
|
+
(requestedName ||
|
|
47
|
+
!project.env.SIGNING_AUTHORITY_ENDPOINT ||
|
|
48
|
+
authority.signingAuthority.endpoint === project.env.SIGNING_AUTHORITY_ENDPOINT)
|
|
49
|
+
);
|
|
50
|
+
if (registered) {
|
|
51
|
+
const endpoint = registered.signingAuthority.endpoint;
|
|
52
|
+
if (!registered.relationship.isPrimary) {
|
|
53
|
+
if (!(await learnCard.invoke.setPrimaryRegisteredSigningAuthority(endpoint, name))) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'Could not select the primary signing authority. Please try again.'
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (options.persist !== false) {
|
|
60
|
+
await saveProject(project, {
|
|
61
|
+
SIGNING_AUTHORITY_NAME: name,
|
|
62
|
+
SIGNING_AUTHORITY_ENDPOINT: endpoint,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
out.log(`Signing authority "${name}" is already your primary.`);
|
|
66
|
+
return { name, endpoint, did: registered.relationship.did, alreadyConfigured: true };
|
|
67
|
+
}
|
|
68
|
+
const hosted = await learnCard.invoke.getSigningAuthorities();
|
|
69
|
+
if (!hosted) throw new Error('Could not look up hosted signing authorities. Please try again.');
|
|
70
|
+
const existing = hosted.find(authority => authority.name === name);
|
|
71
|
+
const authority = existing || (await learnCard.invoke.createSigningAuthority(name));
|
|
72
|
+
if (!authority || !authority.endpoint || !authority.did)
|
|
73
|
+
throw new Error('Could not create signing authority. Please try again.');
|
|
74
|
+
const registeredSuccessfully = await learnCard.invoke.registerSigningAuthority(
|
|
75
|
+
authority.endpoint,
|
|
76
|
+
authority.name,
|
|
77
|
+
authority.did
|
|
78
|
+
);
|
|
79
|
+
if (!registeredSuccessfully)
|
|
80
|
+
throw new Error('Could not register signing authority. Please try again.');
|
|
81
|
+
if (
|
|
82
|
+
!(await learnCard.invoke.setPrimaryRegisteredSigningAuthority(
|
|
83
|
+
authority.endpoint,
|
|
84
|
+
authority.name
|
|
85
|
+
))
|
|
86
|
+
) {
|
|
87
|
+
throw new Error('Could not select the primary signing authority. Please try again.');
|
|
88
|
+
}
|
|
89
|
+
if (options.persist !== false) {
|
|
90
|
+
await saveProject(project, {
|
|
91
|
+
SIGNING_AUTHORITY_NAME: authority.name,
|
|
92
|
+
SIGNING_AUTHORITY_ENDPOINT: authority.endpoint,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
out.log(`LearnCard will now sign credentials for ${project.env.PROFILE_ID}.`);
|
|
96
|
+
out.log('What this did:');
|
|
97
|
+
out.log(
|
|
98
|
+
existing
|
|
99
|
+
? ` const authority = (await learnCard.invoke.getSigningAuthorities()).find(authority => authority.name === ${JSON.stringify(name)});`
|
|
100
|
+
: ` const authority = await learnCard.invoke.createSigningAuthority(${JSON.stringify(name)});`
|
|
101
|
+
);
|
|
102
|
+
out.log(
|
|
103
|
+
' await learnCard.invoke.registerSigningAuthority(authority.endpoint, authority.name, authority.did);'
|
|
104
|
+
);
|
|
105
|
+
out.log(
|
|
106
|
+
' await learnCard.invoke.setPrimaryRegisteredSigningAuthority(authority.endpoint, authority.name);'
|
|
107
|
+
);
|
|
108
|
+
return {
|
|
109
|
+
name: authority.name,
|
|
110
|
+
endpoint: authority.endpoint,
|
|
111
|
+
did: authority.did,
|
|
112
|
+
alreadyConfigured: false,
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
type SetupSigningOptions = ProjectOptions & { endpoint?: string; did?: string };
|
|
117
|
+
|
|
118
|
+
/** Register a signing service you run yourself instead of a LearnCard-hosted one. */
|
|
119
|
+
const registerOwnAuthority = async (
|
|
120
|
+
project: Project,
|
|
121
|
+
learnCard: {
|
|
122
|
+
invoke: Pick<
|
|
123
|
+
NetworkCard['invoke'],
|
|
124
|
+
'registerSigningAuthority' | 'setPrimaryRegisteredSigningAuthority'
|
|
125
|
+
>;
|
|
126
|
+
},
|
|
127
|
+
name: string,
|
|
128
|
+
endpoint: string,
|
|
129
|
+
did: string
|
|
130
|
+
) => {
|
|
131
|
+
if (!/^https:\/\//.test(endpoint)) throw new Error('--endpoint must be an https:// URL.');
|
|
132
|
+
if (!/^did:/.test(did)) throw new Error('--did must be a DID (did:web:..., did:key:...).');
|
|
133
|
+
if (!(await learnCard.invoke.registerSigningAuthority(endpoint, name, did)))
|
|
134
|
+
throw new Error('Could not register the signing authority.');
|
|
135
|
+
if (!(await learnCard.invoke.setPrimaryRegisteredSigningAuthority(endpoint, name)))
|
|
136
|
+
throw new Error('Could not set the signing authority as primary.');
|
|
137
|
+
await saveProject(project, {
|
|
138
|
+
SIGNING_AUTHORITY_NAME: name,
|
|
139
|
+
SIGNING_AUTHORITY_ENDPOINT: endpoint,
|
|
140
|
+
});
|
|
141
|
+
out.log(`Your service at ${endpoint} will now sign credentials for this profile.`);
|
|
142
|
+
out.log('What this did:');
|
|
143
|
+
out.log(
|
|
144
|
+
` await learnCard.invoke.registerSigningAuthority(${JSON.stringify(endpoint)}, ${JSON.stringify(name)}, ${JSON.stringify(did)});`
|
|
145
|
+
);
|
|
146
|
+
out.log(
|
|
147
|
+
` await learnCard.invoke.setPrimaryRegisteredSigningAuthority(${JSON.stringify(endpoint)}, ${JSON.stringify(name)});`
|
|
148
|
+
);
|
|
149
|
+
return { name, endpoint, did, alreadyConfigured: false };
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const runSetupSigning = async (options: SetupSigningOptions): Promise<void> => {
|
|
153
|
+
const project = await loadProject(process.cwd());
|
|
154
|
+
const identity = await ensureIdentity(project, { ...options, name: undefined });
|
|
155
|
+
if ((options.endpoint && !options.did) || (!options.endpoint && options.did))
|
|
156
|
+
throw new Error('Pass both --endpoint and --did to register your own signing service.');
|
|
157
|
+
let authority;
|
|
158
|
+
if (options.endpoint) {
|
|
159
|
+
const learnCard = await connect(project, options);
|
|
160
|
+
await ensureProfile(learnCard, identity, project);
|
|
161
|
+
authority = await registerOwnAuthority(
|
|
162
|
+
project,
|
|
163
|
+
learnCard,
|
|
164
|
+
options.name ?? 'my-issuer',
|
|
165
|
+
options.endpoint,
|
|
166
|
+
options.did!
|
|
167
|
+
);
|
|
168
|
+
} else {
|
|
169
|
+
const learnCard = await connect(project, { ...options, lca: true });
|
|
170
|
+
await ensureProfile(learnCard, identity, project);
|
|
171
|
+
authority = await setupSigning(project, learnCard, options.name);
|
|
172
|
+
}
|
|
173
|
+
out.set({
|
|
174
|
+
profileId: identity.profileId,
|
|
175
|
+
signingAuthority: {
|
|
176
|
+
name: authority.name,
|
|
177
|
+
endpoint: authority.endpoint,
|
|
178
|
+
did: authority.did,
|
|
179
|
+
},
|
|
180
|
+
alreadyConfigured: authority.alreadyConfigured,
|
|
181
|
+
});
|
|
182
|
+
out.log(
|
|
183
|
+
'Send from a template: npx @learncard/cli send you@example.com --template\nSee it in the app: npx @learncard/cli open'
|
|
184
|
+
);
|
|
185
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { out } from './out';
|
|
4
|
+
|
|
5
|
+
/** Preserve files a developer may already have customized. Returns whether this run wrote it. */
|
|
6
|
+
export const writeSnippet = async (filename: string, source: string): Promise<boolean> => {
|
|
7
|
+
try {
|
|
8
|
+
await fs.writeFile(path.join(process.cwd(), filename), source, { flag: 'wx' });
|
|
9
|
+
out.log(`Wrote ./${filename}`);
|
|
10
|
+
return true;
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
13
|
+
out.log(`Kept existing ./${filename}`);
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatEvent, summarize } from './status';
|
|
3
|
+
|
|
4
|
+
const created = {
|
|
5
|
+
activityId: 'a1',
|
|
6
|
+
eventType: 'CREATED',
|
|
7
|
+
timestamp: '2026-09-11T18:12:27.116Z',
|
|
8
|
+
recipientType: 'email',
|
|
9
|
+
recipientIdentifier: 'jane@example.com',
|
|
10
|
+
boost: { name: 'Quickstart Complete' },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe('status', () => {
|
|
14
|
+
it('reports the latest event as the state and keeps the chain newest-first', () => {
|
|
15
|
+
const claimed = {
|
|
16
|
+
...created,
|
|
17
|
+
eventType: 'CLAIMED',
|
|
18
|
+
timestamp: '2026-09-11T18:20:00.000Z',
|
|
19
|
+
credentialUri: 'lc:network:x/trpc:credential:1',
|
|
20
|
+
};
|
|
21
|
+
const s = summarize([created, claimed]);
|
|
22
|
+
expect(s.state).toBe('CLAIMED');
|
|
23
|
+
expect(s.credentialUri).toBe('lc:network:x/trpc:credential:1');
|
|
24
|
+
expect(s.events.map(e => e.type)).toEqual(['CLAIMED', 'CREATED']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('surfaces the failure reason on FAILED events', () => {
|
|
28
|
+
const failed = { ...created, eventType: 'FAILED', metadata: { error: 'bad email' } };
|
|
29
|
+
expect(formatEvent(failed)).toContain('FAILED');
|
|
30
|
+
expect(formatEvent(failed)).toContain('bad email');
|
|
31
|
+
expect(summarize([failed]).events[0]).toMatchObject({ type: 'FAILED', error: 'bad email' });
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { connect, ensureIdentity, loadProject, type ProjectOptions } from './project';
|
|
2
|
+
import { out } from './out';
|
|
3
|
+
|
|
4
|
+
type ActivityEvent = {
|
|
5
|
+
activityId: string;
|
|
6
|
+
eventType: string;
|
|
7
|
+
timestamp: string;
|
|
8
|
+
recipientType: string;
|
|
9
|
+
recipientIdentifier: string;
|
|
10
|
+
credentialUri?: string;
|
|
11
|
+
status?: string;
|
|
12
|
+
boost?: { name?: string } | null;
|
|
13
|
+
metadata?: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const LATEST_FIRST = (a: ActivityEvent, b: ActivityEvent) => b.timestamp.localeCompare(a.timestamp);
|
|
17
|
+
|
|
18
|
+
/** One human line per event: time, what happened, to whom. */
|
|
19
|
+
export const formatEvent = (event: ActivityEvent): string => {
|
|
20
|
+
const when = event.timestamp.replace('T', ' ').slice(0, 19);
|
|
21
|
+
const failure = event.eventType === 'FAILED' ? ` ${String(event.metadata?.error ?? '')}` : '';
|
|
22
|
+
return `${when} ${event.eventType.padEnd(9)} ${event.recipientIdentifier}${failure}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Collapse a chain into what a developer wants to know: the current state. */
|
|
26
|
+
export const summarize = (chain: ActivityEvent[]) => {
|
|
27
|
+
const ordered = [...chain].sort(LATEST_FIRST);
|
|
28
|
+
const latest = ordered[0];
|
|
29
|
+
const claimed = chain.find(e => e.eventType === 'CLAIMED');
|
|
30
|
+
return {
|
|
31
|
+
state: latest?.eventType ?? 'UNKNOWN',
|
|
32
|
+
recipient: latest?.recipientIdentifier,
|
|
33
|
+
template: latest?.boost?.name,
|
|
34
|
+
credentialUri: claimed?.credentialUri ?? latest?.credentialUri,
|
|
35
|
+
credentialStatus: latest?.status,
|
|
36
|
+
events: ordered.map(e => ({
|
|
37
|
+
type: e.eventType,
|
|
38
|
+
at: e.timestamp,
|
|
39
|
+
...(e.eventType === 'FAILED' && e.metadata?.error ? { error: e.metadata.error } : {}),
|
|
40
|
+
})),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type StatusOptions = ProjectOptions & { limit?: string; event?: string };
|
|
45
|
+
|
|
46
|
+
export const runStatus = async (activityId: string | undefined, options: StatusOptions) => {
|
|
47
|
+
const project = await loadProject(process.cwd());
|
|
48
|
+
if (!project.env.SECURE_SEED)
|
|
49
|
+
throw new Error(
|
|
50
|
+
'No SECURE_SEED in .env. Send something first: npx @learncard/cli send you@example.com'
|
|
51
|
+
);
|
|
52
|
+
await ensureIdentity(project, options);
|
|
53
|
+
const learnCard = await connect(project, options);
|
|
54
|
+
|
|
55
|
+
if (activityId) {
|
|
56
|
+
const chain = (await learnCard.invoke.getActivityChain({ activityId })) as ActivityEvent[];
|
|
57
|
+
if (!chain.length) throw new Error(`No activity found for ${activityId}.`);
|
|
58
|
+
const summary = summarize(chain);
|
|
59
|
+
out.log(`${summary.state} ${summary.template ?? ''} → ${summary.recipient}`);
|
|
60
|
+
for (const e of [...chain].sort(LATEST_FIRST)) out.log(` ${formatEvent(e)}`);
|
|
61
|
+
if (summary.credentialUri) out.log(`Credential: ${summary.credentialUri}`);
|
|
62
|
+
if (summary.state !== 'CLAIMED')
|
|
63
|
+
out.log('Not claimed yet. Re-run to check again, or use `webhook` to be told.');
|
|
64
|
+
out.set({ activityId, ...summary });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const limit = Number(options.limit ?? 20);
|
|
69
|
+
const eventType = options.event?.toUpperCase();
|
|
70
|
+
const page = await learnCard.invoke.getMyActivities({
|
|
71
|
+
limit,
|
|
72
|
+
...(eventType ? { eventType: eventType as never } : {}),
|
|
73
|
+
});
|
|
74
|
+
const records = page.records as ActivityEvent[];
|
|
75
|
+
if (!records.length) {
|
|
76
|
+
out.log('No sends yet. Try: npx @learncard/cli send you@example.com');
|
|
77
|
+
out.set({ activities: [] });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
out.log('activityId state recipient');
|
|
81
|
+
for (const r of records)
|
|
82
|
+
out.log(`${r.activityId} ${r.eventType.padEnd(9)} ${r.recipientIdentifier}`);
|
|
83
|
+
if (page.hasMore)
|
|
84
|
+
out.log(`…more. Raise --limit or filter with --event claimed|delivered|created.`);
|
|
85
|
+
out.log('Details for one: npx @learncard/cli status <activityId>');
|
|
86
|
+
out.set({
|
|
87
|
+
activities: records.map(r => ({
|
|
88
|
+
activityId: r.activityId,
|
|
89
|
+
state: r.eventType,
|
|
90
|
+
recipient: r.recipientIdentifier,
|
|
91
|
+
template: r.boost?.name,
|
|
92
|
+
at: r.timestamp,
|
|
93
|
+
})),
|
|
94
|
+
hasMore: page.hasMore,
|
|
95
|
+
});
|
|
96
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { validateScope, SCOPE_RESOURCES } from './token';
|
|
3
|
+
import { SEND_SH } from './generated/snippets';
|
|
4
|
+
import { withEnvTokenLoader } from './project';
|
|
5
|
+
|
|
6
|
+
describe('token scopes', () => {
|
|
7
|
+
it('accepts real resource names and wildcards', () => {
|
|
8
|
+
for (const resource of SCOPE_RESOURCES)
|
|
9
|
+
expect(validateScope(`${resource}:write`)).toBe(`${resource}:write`);
|
|
10
|
+
expect(validateScope(' boosts:write inbox:read ')).toBe('boosts:write inbox:read');
|
|
11
|
+
expect(validateScope('*:*')).toBe('*:*');
|
|
12
|
+
expect(validateScope('')).toBe('');
|
|
13
|
+
});
|
|
14
|
+
it('rejects unknown resources, stale aliases, and malformed actions', () => {
|
|
15
|
+
expect(() => validateScope('unicorns:write')).toThrow('Unknown scope resource');
|
|
16
|
+
expect(() => validateScope('profile:write')).toThrow('profiles');
|
|
17
|
+
expect(() => validateScope('boosts:admin')).toThrow('read, write, delete');
|
|
18
|
+
expect(() => validateScope('boosts:write:extra')).toThrow();
|
|
19
|
+
});
|
|
20
|
+
it('writes send.sh that reads API_TOKEN from .env without executing it', () => {
|
|
21
|
+
const written = withEnvTokenLoader(SEND_SH);
|
|
22
|
+
expect(written).toContain("sed -n 's/^API_TOKEN=//p' .env");
|
|
23
|
+
expect(written).not.toMatch(/tr -d/);
|
|
24
|
+
expect(written).toContain('Bearer $TOKEN');
|
|
25
|
+
expect(written).not.toContain('. .env');
|
|
26
|
+
expect(written).not.toContain('source .env');
|
|
27
|
+
});
|
|
28
|
+
});
|