@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +61 -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,448 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ DEFAULT_REQUIRED_SCOPES,
4
+ didWebCheck,
5
+ identityCheck,
6
+ networkCheck,
7
+ signingServiceCheck,
8
+ refreshEnabledCheck,
9
+ signingAuthorityCheck,
10
+ tokenScopesCheck,
11
+ trustedRegistryCheck,
12
+ webhookCheck,
13
+ type DoctorCard,
14
+ type DoctorContext,
15
+ } from './checks';
16
+ import type { AuthGrantWithActAs } from '../auth-grant';
17
+
18
+ const services = {
19
+ network: 'https://network.learncard.com/trpc',
20
+ cloud: undefined,
21
+ lcaAPI: undefined,
22
+ };
23
+
24
+ const createLearnCard = (overrides: Partial<DoctorCard['invoke']> = {}): DoctorCard => ({
25
+ invoke: {
26
+ getProfile: vi.fn().mockResolvedValue(undefined),
27
+ getRegisteredSigningAuthorities: vi.fn().mockResolvedValue([]),
28
+ getAuthGrants: vi.fn().mockResolvedValue([]),
29
+ getCredentialRefreshHistory: vi.fn().mockResolvedValue({ records: [], hasMore: false }),
30
+ issueCredential: vi.fn().mockResolvedValue({ proof: {} }),
31
+ verifyCredential: vi.fn().mockResolvedValue({ checks: [], warnings: [], errors: [] }),
32
+ ...overrides,
33
+ },
34
+ id: { did: vi.fn().mockReturnValue('did:key:z6Mkdefault') },
35
+ });
36
+
37
+ const createContext = (overrides: Partial<DoctorContext> = {}): DoctorContext => ({
38
+ project: { env: {}, envPath: '/unused/.env', existing: '' },
39
+ services,
40
+ learnCard: createLearnCard(),
41
+ fetch: vi.fn(),
42
+ requiredScopes: DEFAULT_REQUIRED_SCOPES,
43
+ ...overrides,
44
+ });
45
+
46
+ const okResponse = (body: unknown, status = 200) =>
47
+ ({ ok: status < 300, status, json: async () => body }) as Awaited<ReturnType<typeof fetch>>;
48
+
49
+ describe('identityCheck', () => {
50
+ it('passes when the network profile matches .env', async () => {
51
+ const ctx = createContext({
52
+ project: {
53
+ env: { SECURE_SEED: 's', PROFILE_ID: 'alice' },
54
+ envPath: '/x',
55
+ existing: '',
56
+ },
57
+ learnCard: createLearnCard({
58
+ getProfile: vi.fn().mockResolvedValue({ profileId: 'alice' }),
59
+ }),
60
+ });
61
+ const result = await identityCheck.run(ctx);
62
+ expect(result.status).toBe('pass');
63
+ });
64
+
65
+ it('fails when .env is missing an identity', async () => {
66
+ const result = await identityCheck.run(createContext());
67
+ expect(result.status).toBe('fail');
68
+ expect(result.fix).toContain('init');
69
+ });
70
+ });
71
+
72
+ describe('networkCheck', () => {
73
+ it('passes when the health-check endpoint answers 2xx', async () => {
74
+ const fetch = vi.fn().mockResolvedValue(okResponse('Healthy'));
75
+ const result = await networkCheck.run(createContext({ fetch }));
76
+ expect(result.status).toBe('pass');
77
+ expect(fetch).toHaveBeenCalledWith(
78
+ 'https://network.learncard.com/api/health-check',
79
+ expect.anything()
80
+ );
81
+ });
82
+
83
+ it('fails when the network is unreachable', async () => {
84
+ const fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
85
+ const result = await networkCheck.run(createContext({ fetch }));
86
+ expect(result.status).toBe('fail');
87
+ expect(result.detail).toContain('ECONNREFUSED');
88
+ });
89
+ });
90
+
91
+ describe('signingServiceCheck', () => {
92
+ it('skips when no LCA_API_URL is configured', async () => {
93
+ const fetch = vi.fn();
94
+ const result = await signingServiceCheck.run(createContext({ fetch }));
95
+ expect(result.status).toBe('skip');
96
+ expect(fetch).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it('passes when the signing service health-check answers 2xx', async () => {
100
+ const fetch = vi.fn().mockResolvedValue(okResponse('Healthy'));
101
+ const result = await signingServiceCheck.run(
102
+ createContext({
103
+ fetch,
104
+ services: { ...services, lcaAPI: 'http://localhost:5100/trpc' },
105
+ })
106
+ );
107
+ expect(result.status).toBe('pass');
108
+ expect(fetch).toHaveBeenCalledWith(
109
+ 'http://localhost:5100/api/health-check',
110
+ expect.anything()
111
+ );
112
+ });
113
+
114
+ it('fails with a container hint when the socket closes', async () => {
115
+ const fetch = vi.fn().mockRejectedValue(new Error('fetch failed'));
116
+ const result = await signingServiceCheck.run(
117
+ createContext({
118
+ fetch,
119
+ services: { ...services, lcaAPI: 'http://localhost:5100/trpc' },
120
+ })
121
+ );
122
+ expect(result.status).toBe('fail');
123
+ expect(result.fix).toContain('LCA_API_URL');
124
+ });
125
+ });
126
+
127
+ describe('tokenScopesCheck', () => {
128
+ it('warns when a token has no grant metadata, even if a scopeless grant exists', async () => {
129
+ const result = await tokenScopesCheck.run(
130
+ createContext({
131
+ project: { env: { API_TOKEN: 'secret' }, envPath: '/x', existing: '' },
132
+ learnCard: createLearnCard({
133
+ getAuthGrants: vi.fn().mockResolvedValue([{ id: 'g1', status: 'active' }]),
134
+ }),
135
+ })
136
+ );
137
+ expect(result.status).toBe('warn');
138
+ expect(result.fix).toContain('API_TOKEN_GRANT_ID');
139
+ });
140
+
141
+ it('does not count expired covering grants when no token is configured', async () => {
142
+ const result = await tokenScopesCheck.run(
143
+ createContext({
144
+ learnCard: createLearnCard({
145
+ getAuthGrants: vi.fn().mockResolvedValue([
146
+ {
147
+ id: 'g1',
148
+ status: 'active',
149
+ scope: '*:*',
150
+ expiresAt: '2000-01-01T00:00:00.000Z',
151
+ },
152
+ ]),
153
+ }),
154
+ })
155
+ );
156
+ expect(result.status).toBe('warn');
157
+ });
158
+
159
+ it('matches a token by grant ID without scope metadata', async () => {
160
+ const result = await tokenScopesCheck.run(
161
+ createContext({
162
+ project: {
163
+ env: { API_TOKEN: 'secret', API_TOKEN_GRANT_ID: 'g1' },
164
+ envPath: '/x',
165
+ existing: '',
166
+ },
167
+ learnCard: createLearnCard({
168
+ getAuthGrants: vi
169
+ .fn()
170
+ .mockResolvedValue([{ id: 'g1', status: 'active', scope: '*:*' }]),
171
+ }),
172
+ })
173
+ );
174
+ expect(result.status).toBe('pass');
175
+ });
176
+ it('passes when the active grant covers every required scope', async () => {
177
+ const scope = DEFAULT_REQUIRED_SCOPES.join(' ');
178
+ const ctx = createContext({
179
+ project: {
180
+ env: { API_TOKEN: 'secret', API_TOKEN_SCOPE: scope },
181
+ envPath: '/x',
182
+ existing: '',
183
+ },
184
+ learnCard: createLearnCard({
185
+ getAuthGrants: vi.fn().mockResolvedValue([{ id: 'g1', status: 'active', scope }]),
186
+ }),
187
+ });
188
+ const result = await tokenScopesCheck.run(ctx);
189
+ expect(result.status).toBe('pass');
190
+ });
191
+
192
+ it('warns when no API_TOKEN is configured and no grant covers the scopes', async () => {
193
+ const result = await tokenScopesCheck.run(createContext());
194
+ expect(result.status).toBe('warn');
195
+ expect(result.fix).toContain('token --scope');
196
+ });
197
+
198
+ it('passes without API_TOKEN when an active grant covers the scopes (token kept outside .env)', async () => {
199
+ const scope = DEFAULT_REQUIRED_SCOPES.join(' ');
200
+ const ctx = createContext({
201
+ learnCard: createLearnCard({
202
+ getAuthGrants: vi
203
+ .fn()
204
+ .mockResolvedValue([
205
+ { id: 'g1', name: 'ex-clr-issuer', status: 'active', scope },
206
+ ]),
207
+ }),
208
+ });
209
+ const result = await tokenScopesCheck.run(ctx);
210
+ expect(result.status).toBe('pass');
211
+ expect(result.detail).toContain('ex-clr-issuer');
212
+ });
213
+
214
+ it('includes a comma-separated actAs list in the pass detail', async () => {
215
+ const scope = DEFAULT_REQUIRED_SCOPES.join(' ');
216
+ const grant: AuthGrantWithActAs = {
217
+ id: 'g1',
218
+ name: 'ex-clr-issuer',
219
+ status: 'active',
220
+ scope,
221
+ actAs: 'sc-greenville,sc-north',
222
+ };
223
+ const ctx = createContext({
224
+ learnCard: createLearnCard({ getAuthGrants: vi.fn().mockResolvedValue([grant]) }),
225
+ });
226
+ const result = await tokenScopesCheck.run(ctx);
227
+ expect(result.status).toBe('pass');
228
+ expect(result.detail).toContain('may act as: sc-greenville, sc-north');
229
+ });
230
+
231
+ it('reports "any managed profile" in the pass detail when actAs is "*"', async () => {
232
+ const scope = DEFAULT_REQUIRED_SCOPES.join(' ');
233
+ const grant: AuthGrantWithActAs = {
234
+ id: 'g1',
235
+ name: 'star-issuer',
236
+ status: 'active',
237
+ scope,
238
+ actAs: '*',
239
+ };
240
+ const ctx = createContext({
241
+ learnCard: createLearnCard({ getAuthGrants: vi.fn().mockResolvedValue([grant]) }),
242
+ });
243
+ const result = await tokenScopesCheck.run(ctx);
244
+ expect(result.detail).toContain('may act as: any managed profile');
245
+ });
246
+
247
+ it('reports "no delegation" in the pass detail when actAs is absent', async () => {
248
+ const scope = DEFAULT_REQUIRED_SCOPES.join(' ');
249
+ const ctx = createContext({
250
+ project: {
251
+ env: { API_TOKEN: 'secret', API_TOKEN_SCOPE: scope },
252
+ envPath: '/x',
253
+ existing: '',
254
+ },
255
+ learnCard: createLearnCard({
256
+ getAuthGrants: vi.fn().mockResolvedValue([{ id: 'g1', status: 'active', scope }]),
257
+ }),
258
+ });
259
+ const result = await tokenScopesCheck.run(ctx);
260
+ expect(result.detail).toContain('may act as: no delegation');
261
+ });
262
+
263
+ it('fails when the matching grant has expired', async () => {
264
+ const scope = DEFAULT_REQUIRED_SCOPES.join(' ');
265
+ const ctx = createContext({
266
+ project: {
267
+ env: { API_TOKEN: 'secret', API_TOKEN_SCOPE: scope },
268
+ envPath: '/x',
269
+ existing: '',
270
+ },
271
+ learnCard: createLearnCard({
272
+ getAuthGrants: vi.fn().mockResolvedValue([
273
+ {
274
+ id: 'g1',
275
+ status: 'active',
276
+ scope,
277
+ expiresAt: '2000-01-01T00:00:00.000Z',
278
+ },
279
+ ]),
280
+ }),
281
+ });
282
+ const result = await tokenScopesCheck.run(ctx);
283
+ expect(result.status).toBe('fail');
284
+ expect(result.detail).toContain('expired');
285
+ });
286
+ });
287
+
288
+ describe('signingAuthorityCheck', () => {
289
+ it('passes when a primary https signer test-signs and verifies', async () => {
290
+ const ctx = createContext({
291
+ learnCard: createLearnCard({
292
+ getRegisteredSigningAuthorities: vi.fn().mockResolvedValue([
293
+ {
294
+ signingAuthority: { endpoint: 'https://sign.example/api' },
295
+ relationship: {
296
+ name: 'default-issuer',
297
+ did: 'did:key:sa',
298
+ isPrimary: true,
299
+ },
300
+ },
301
+ ]),
302
+ }),
303
+ });
304
+ const result = await signingAuthorityCheck.run(ctx);
305
+ expect(result.status).toBe('pass');
306
+ });
307
+
308
+ const primaryAt = (endpoint: string) =>
309
+ createLearnCard({
310
+ getRegisteredSigningAuthorities: vi.fn().mockResolvedValue([
311
+ {
312
+ signingAuthority: { endpoint },
313
+ relationship: { name: 'x', did: 'did:key:sa', isPrimary: true },
314
+ },
315
+ ]),
316
+ });
317
+
318
+ it('accepts a plain-http signer on localhost', async () => {
319
+ const result = await signingAuthorityCheck.run(
320
+ createContext({ learnCard: primaryAt('http://localhost:5100/api') })
321
+ );
322
+ expect(result.status).toBe('pass');
323
+ });
324
+
325
+ it('fails a plain-http signer on a non-loopback host', async () => {
326
+ const result = await signingAuthorityCheck.run(
327
+ createContext({ learnCard: primaryAt('http://sign.example/api') })
328
+ );
329
+ expect(result.status).toBe('fail');
330
+ expect(result.detail).toContain('not https');
331
+ });
332
+
333
+ it('fails when there is no primary signing authority', async () => {
334
+ const result = await signingAuthorityCheck.run(createContext());
335
+ expect(result.status).toBe('fail');
336
+ expect(result.fix).toContain('setup-signing');
337
+ });
338
+ });
339
+
340
+ describe('didWebCheck', () => {
341
+ it('passes when did.json resolves with a verification method', async () => {
342
+ const did = 'did:web:network.learncard.com:users:alice';
343
+ const ctx = createContext({
344
+ project: { env: { PROFILE_ID: 'alice' }, envPath: '/x', existing: '' },
345
+ fetch: vi
346
+ .fn()
347
+ .mockResolvedValue(okResponse({ verificationMethod: [{ id: `${did}#owner` }] })),
348
+ learnCard: createLearnCard({
349
+ getRegisteredSigningAuthorities: vi.fn().mockResolvedValue([
350
+ {
351
+ signingAuthority: { endpoint: 'https://sign.example/api' },
352
+ relationship: { name: 'x', did, isPrimary: true },
353
+ },
354
+ ]),
355
+ }),
356
+ });
357
+ ctx.learnCard.id.did = vi.fn().mockReturnValue(did);
358
+ const result = await didWebCheck.run(ctx);
359
+ expect(result.status).toBe('pass');
360
+ });
361
+
362
+ it('warns when the profile has no did:web yet', async () => {
363
+ const ctx = createContext();
364
+ ctx.learnCard.id.did = vi.fn().mockImplementation(() => {
365
+ throw new Error('Unspported Did Method');
366
+ });
367
+ const result = await didWebCheck.run(ctx);
368
+ expect(result.status).toBe('warn');
369
+ expect(result.detail).toContain('did:key');
370
+ });
371
+ });
372
+
373
+ describe('webhookCheck', () => {
374
+ it('skips when no webhook URL is configured', async () => {
375
+ const result = await webhookCheck.run(createContext());
376
+ expect(result.status).toBe('skip');
377
+ });
378
+
379
+ it('passes when the webhook accepts the doctor ping', async () => {
380
+ const fetch = vi.fn().mockResolvedValue(okResponse(undefined));
381
+ const result = await webhookCheck.run(
382
+ createContext({ fetch, webhookUrl: 'https://hooks.example/doctor' })
383
+ );
384
+ expect(result.status).toBe('pass');
385
+ const [, init] = fetch.mock.calls[0] as [string, RequestInit];
386
+ expect(init.method).toBe('POST');
387
+ expect((init.headers as Record<string, string>)['X-LearnCard-Doctor']).toBe('1');
388
+ });
389
+
390
+ it('fails on a network error reaching the webhook', async () => {
391
+ const fetch = vi.fn().mockRejectedValue(new Error('timeout'));
392
+ const result = await webhookCheck.run(
393
+ createContext({ fetch, webhookUrl: 'https://hooks.example/doctor' })
394
+ );
395
+ expect(result.status).toBe('fail');
396
+ });
397
+ });
398
+
399
+ describe('refreshEnabledCheck', () => {
400
+ it('passes when the unknown refreshId 404s without the disabled message', async () => {
401
+ const ctx = createContext({
402
+ learnCard: createLearnCard({
403
+ getCredentialRefreshHistory: vi
404
+ .fn()
405
+ .mockRejectedValue(new Error('Credential refresh not found')),
406
+ }),
407
+ });
408
+ const result = await refreshEnabledCheck.run(ctx);
409
+ expect(result.status).toBe('pass');
410
+ });
411
+
412
+ it('fails when the network reports the feature is not available', async () => {
413
+ const ctx = createContext({
414
+ learnCard: createLearnCard({
415
+ getCredentialRefreshHistory: vi
416
+ .fn()
417
+ .mockRejectedValue(new Error('Credential refresh is not available')),
418
+ }),
419
+ });
420
+ const result = await refreshEnabledCheck.run(ctx);
421
+ expect(result.status).toBe('fail');
422
+ expect(result.fix).toContain('staging');
423
+ });
424
+
425
+ it('warns instead of passing when the probe fails for an unrelated reason', async () => {
426
+ const ctx = createContext({
427
+ learnCard: createLearnCard({
428
+ getCredentialRefreshHistory: vi.fn().mockRejectedValue(new Error('fetch failed')),
429
+ }),
430
+ });
431
+ const result = await refreshEnabledCheck.run(ctx);
432
+ expect(result.status).toBe('warn');
433
+ expect(result.detail).toContain('fetch failed');
434
+ });
435
+ });
436
+
437
+ describe('trustedRegistryCheck', () => {
438
+ it('is always a manual skip', async () => {
439
+ const result = await trustedRegistryCheck.run(createContext());
440
+ expect(result.status).toBe('skip');
441
+ });
442
+
443
+ it('mentions phone and state_student_id addressing in the detail', async () => {
444
+ const result = await trustedRegistryCheck.run(createContext());
445
+ expect(result.detail).toContain('phone');
446
+ expect(result.detail).toContain('state_student_id');
447
+ });
448
+ });