@learncard/cli 3.6.8 → 4.0.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 (72) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +36 -12
  3. package/dist/index.js +6673 -2038
  4. package/package.json +27 -15
  5. package/project.json +0 -42
  6. package/rollup.config.js +0 -34
  7. package/scripts/embed-snippets.ts +0 -47
  8. package/src/auth-grant.test.ts +0 -54
  9. package/src/auth-grant.ts +0 -34
  10. package/src/clr/validate.test.ts +0 -65
  11. package/src/clr/validate.ts +0 -242
  12. package/src/clr.ts +0 -119
  13. package/src/consent-contract.ts +0 -116
  14. package/src/demo-inbox-refresh.test.ts +0 -737
  15. package/src/demo-inbox-refresh.ts +0 -804
  16. package/src/demo-refresh-command.test.ts +0 -57
  17. package/src/demo-refresh-command.ts +0 -22
  18. package/src/demo-refresh-ui.test.ts +0 -66
  19. package/src/demo-refresh-ui.ts +0 -65
  20. package/src/demo-refresh.test.ts +0 -140
  21. package/src/demo-refresh.ts +0 -309
  22. package/src/doctor/checks.test.ts +0 -448
  23. package/src/doctor/checks.ts +0 -497
  24. package/src/doctor.test.ts +0 -67
  25. package/src/doctor.ts +0 -118
  26. package/src/embed.ts +0 -102
  27. package/src/inbox.test.ts +0 -257
  28. package/src/inbox.ts +0 -221
  29. package/src/index.tsx +0 -876
  30. package/src/init.ts +0 -26
  31. package/src/open.test.ts +0 -40
  32. package/src/open.ts +0 -186
  33. package/src/org/apply.test.ts +0 -1108
  34. package/src/org/apply.ts +0 -924
  35. package/src/org/branding.test.ts +0 -60
  36. package/src/org/diff.ts +0 -14
  37. package/src/org/load.ts +0 -50
  38. package/src/org/schema.test.ts +0 -256
  39. package/src/org/schema.ts +0 -216
  40. package/src/org.ts +0 -124
  41. package/src/out.test.ts +0 -40
  42. package/src/out.ts +0 -16
  43. package/src/phase-two.test.ts +0 -154
  44. package/src/project.test.ts +0 -232
  45. package/src/project.ts +0 -489
  46. package/src/promote.test.ts +0 -142
  47. package/src/promote.ts +0 -202
  48. package/src/random.ts +0 -3
  49. package/src/refresh.test.ts +0 -86
  50. package/src/refresh.ts +0 -93
  51. package/src/replHelpers.test.ts +0 -80
  52. package/src/replHelpers.ts +0 -45
  53. package/src/revoke.test.ts +0 -29
  54. package/src/revoke.ts +0 -65
  55. package/src/send-template.test.ts +0 -20
  56. package/src/send.test.ts +0 -321
  57. package/src/send.ts +0 -373
  58. package/src/setup-signing.test.ts +0 -62
  59. package/src/setup-signing.ts +0 -185
  60. package/src/snippet-files.ts +0 -16
  61. package/src/status.test.ts +0 -33
  62. package/src/status.ts +0 -94
  63. package/src/token.test.ts +0 -28
  64. package/src/token.ts +0 -139
  65. package/src/types.d.ts +0 -17
  66. package/src/verify.test.ts +0 -35
  67. package/src/verify.ts +0 -66
  68. package/src/webhook.ts +0 -247
  69. package/src/whoami.test.ts +0 -67
  70. package/src/whoami.ts +0 -129
  71. package/tsconfig.json +0 -38
  72. package/vitest.config.ts +0 -8
package/src/promote.ts DELETED
@@ -1,202 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import type { Command } from 'commander';
4
-
5
- import {
6
- loadProject,
7
- saveProject,
8
- resolveServices,
9
- PRODUCTION_NETWORK,
10
- STAGING_NETWORK,
11
- type Project,
12
- type ProjectOptions,
13
- } from './project';
14
- import { loadOrgSpec } from './org/load';
15
- import { runOrgApply } from './org';
16
- import { runDoctor, type RunCommand } from './doctor';
17
- import { out } from './out';
18
-
19
- /**
20
- * Per-network resources that must be recreated on the target network after a promotion.
21
- * See docs/how-to-guides/deploy-infrastructure/test-safely.md §"What carries over". The
22
- * one thing that IS portable — your seed, and therefore the resulting did:key — is
23
- * printed separately by `runPromote` rather than listed here.
24
- */
25
- export const PROMOTE_CHECKLIST: readonly string[] = [
26
- 'Profile & profile ID',
27
- 'API tokens',
28
- 'Signing authority registrations',
29
- 'Credential templates',
30
- 'ConsentFlow contracts',
31
- 'Issued & claimed credentials',
32
- 'did:web (host-bound)',
33
- ];
34
-
35
- const NETWORK_ALIASES: Record<string, string> = {
36
- staging: STAGING_NETWORK,
37
- production: PRODUCTION_NETWORK,
38
- };
39
-
40
- /** `resolveServices` only special-cases the literal string "staging"; translate "production" too. */
41
- const resolveNetworkUrl = (network: string): string =>
42
- resolveServices({}, NETWORK_ALIASES[network] ?? network, {}).network;
43
-
44
- const NETWORK_DIR_NAMES: Record<string, string> = {
45
- [STAGING_NETWORK]: 'staging',
46
- [PRODUCTION_NETWORK]: 'production',
47
- };
48
-
49
- /** One project folder per network (`assertProjectNetwork` enforces this); named after the alias when known. */
50
- const networkDirName = (network: string): string =>
51
- NETWORK_DIR_NAMES[network] ?? new URL(network).hostname;
52
-
53
- /** Pure: resolves --from/--to to network URLs and picks the target project folder. */
54
- export const planPromotion = (
55
- from: string,
56
- to: string,
57
- cwd: string = process.cwd()
58
- ): { fromNetwork: string; toNetwork: string; targetDir: string } => {
59
- const fromNetwork = resolveNetworkUrl(from);
60
- const toNetwork = resolveNetworkUrl(to);
61
- if (fromNetwork === toNetwork)
62
- throw new Error(`--from and --to resolve to the same network (${toNetwork}).`);
63
- const targetDir = path.join(cwd, '.learncard', networkDirName(toNetwork));
64
- return { fromNetwork, toNetwork, targetDir };
65
- };
66
-
67
- /** Pure: the source folder's `.env` must actually be on `--from`, or the wrong seed's org gets promoted. */
68
- export const assertSourceNetwork = (
69
- sourceEnv: Record<string, string>,
70
- from: string,
71
- fromNetwork: string
72
- ): void => {
73
- const actual = resolveServices(sourceEnv, undefined, {}).network;
74
- if (actual !== fromNetwork)
75
- throw new Error(
76
- `--from ${from} does not match this folder's network (${actual}). Run promote from the folder that is on ${from}.`
77
- );
78
- };
79
-
80
- /** Pure: a target folder already bound to another seed would be provisioned as a different identity than the preview shows. */
81
- export const assertTargetSeed = (
82
- targetEnv: Record<string, string>,
83
- seed: string,
84
- targetDir: string
85
- ): void => {
86
- if (targetEnv.SECURE_SEED && targetEnv.SECURE_SEED !== seed)
87
- throw new Error(
88
- `${path.join(targetDir, '.env')} already holds a different SECURE_SEED. Promotion carries this folder's seed; move that .env aside or pick a different target before re-running.`
89
- );
90
- };
91
-
92
- export type PromoteOptions = ProjectOptions & {
93
- cwd?: string;
94
- from: string;
95
- to: string;
96
- org: string;
97
- secretsOut?: string;
98
- dryRun?: boolean;
99
- skipDoctor?: boolean;
100
- };
101
-
102
- export const runPromote = async (options: PromoteOptions): Promise<void> => {
103
- const { from, to, org, dryRun, skipDoctor } = options;
104
- const { fromNetwork, toNetwork, targetDir } = planPromotion(from, to, options.cwd);
105
- const secretsOut = options.secretsOut ?? path.join(targetDir, 'secrets.env');
106
- out.log(`Promoting ${org} from ${from} to ${to}`);
107
- out.log(`Target: ${targetDir}`);
108
-
109
- const sourceProject = await loadProject(options.cwd ?? process.cwd());
110
- if (!sourceProject.env.SECURE_SEED)
111
- throw new Error(
112
- `No SECURE_SEED in .env here. Run \`org apply\` against ${from} in this folder first.`
113
- );
114
- assertSourceNetwork(sourceProject.env, from, fromNetwork);
115
-
116
- // Only the seed (and, if present, the matching profile ID) carries over — every
117
- // other resource (tokens, signing authority, templates, contracts) is per-network
118
- // and must be recreated by `org apply` against the target network below.
119
- const carried = {
120
- SECURE_SEED: sourceProject.env.SECURE_SEED,
121
- ...(sourceProject.env.PROFILE_ID ? { PROFILE_ID: sourceProject.env.PROFILE_ID } : {}),
122
- NETWORK_URL: toNetwork,
123
- };
124
-
125
- const targetProject = await loadProject(targetDir);
126
- assertTargetSeed(targetProject.env, carried.SECURE_SEED, targetDir);
127
-
128
- const spec = await loadOrgSpec(org);
129
- if (spec.serviceAccounts?.length && !dryRun)
130
- out.log(`Any new service-account tokens for ${to} go to ${secretsOut}`);
131
-
132
- if (dryRun) {
133
- out.log(
134
- `Dry run: ${targetDir} is not created; previewing against ${to} with the carried-over seed.`
135
- );
136
- const preview: Project = {
137
- env: { ...targetProject.env, ...carried },
138
- envPath: targetProject.envPath,
139
- existing: '',
140
- };
141
- await runOrgApply(org, {
142
- ...options,
143
- project: preview,
144
- network: toNetwork,
145
- dryRun,
146
- secretsOut,
147
- });
148
- } else {
149
- await fs.mkdir(targetDir, { recursive: true });
150
- await saveProject(targetProject, carried);
151
- await runOrgApply(org, {
152
- ...options,
153
- cwd: targetDir,
154
- network: toNetwork,
155
- dryRun,
156
- secretsOut,
157
- });
158
- }
159
-
160
- if (!skipDoctor && !dryRun) {
161
- await runDoctor({ ...options, cwd: targetDir, network: toNetwork });
162
- }
163
-
164
- out.log(`Does not carry over from ${from} — recreate on ${to}:`);
165
- for (const item of PROMOTE_CHECKLIST) out.log(` - ${item}`);
166
- out.log('Carries over: your seed → the same did:key.');
167
- out.log(`Next: cd ${targetDir} && npx @learncard/cli doctor --network ${to}`);
168
-
169
- out.set({ from: fromNetwork, to: toNetwork, targetDir, checklist: PROMOTE_CHECKLIST });
170
- };
171
-
172
- export const registerPromoteCommand = (program: Command, run: RunCommand): void => {
173
- program
174
- .command('promote')
175
- .description('Move an issuer org from one network to another (e.g. staging to production).')
176
- .requiredOption('--from <network>', 'source network tRPC URL or staging|production')
177
- .requiredOption('--to <network>', 'target network tRPC URL or staging|production')
178
- .requiredOption('--org <file>', 'org spec to re-apply on the target network')
179
- .option(
180
- '--secrets-out <path>',
181
- 'where to write the new service-account tokens (default: <target>/secrets.env, mode 0600)'
182
- )
183
- .option('--dry-run', 'preview changes without applying them')
184
- .option('--skip-doctor', 'skip running doctor against the target network afterward')
185
- .option('-y, --yes', 'accept defaults without prompting')
186
- .option('--json', 'print a single JSON result on stdout')
187
- .action(
188
- (options: {
189
- from: string;
190
- to: string;
191
- org: string;
192
- secretsOut?: string;
193
- dryRun?: boolean;
194
- skipDoctor?: boolean;
195
- yes?: boolean;
196
- json?: boolean;
197
- }) =>
198
- run('promote', options, async didkit => {
199
- await runPromote({ ...options, didkit });
200
- })
201
- );
202
- };
package/src/random.ts DELETED
@@ -1,3 +0,0 @@
1
- import crypto from 'crypto';
2
-
3
- export const generateRandomSeed = () => crypto.randomBytes(32).toString('hex');
@@ -1,86 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { formatRefreshVersion, mapRefreshHistoryError, runRefreshHistory } from './refresh';
3
- import { connect, loadProject } from './project';
4
-
5
- const { getCredentialRefreshHistory } = vi.hoisted(() => ({
6
- getCredentialRefreshHistory: vi.fn(),
7
- }));
8
- vi.mock('./project', () => ({
9
- loadProject: vi.fn().mockResolvedValue({ env: {} }),
10
- resolveServices: vi.fn().mockReturnValue({ network: 'https://network.learncard.com/trpc' }),
11
- connect: vi.fn().mockResolvedValue({ invoke: { getCredentialRefreshHistory } }),
12
- }));
13
- vi.mock('./out', () => ({ out: { log: vi.fn(), set: vi.fn() } }));
14
-
15
- describe('refresh history limit', () => {
16
- beforeEach(() => {
17
- vi.clearAllMocks();
18
- getCredentialRefreshHistory.mockResolvedValue({ records: [], hasMore: false });
19
- });
20
-
21
- it.each(['abc', '0', '-1', '1.5', '', 'NaN', 'Infinity'])(
22
- 'rejects %j before connecting',
23
- async limit => {
24
- await expect(runRefreshHistory('refresh-1', { limit })).rejects.toThrow(
25
- 'Invalid --limit'
26
- );
27
- expect(loadProject).not.toHaveBeenCalled();
28
- expect(connect).not.toHaveBeenCalled();
29
- expect(getCredentialRefreshHistory).not.toHaveBeenCalled();
30
- }
31
- );
32
-
33
- it.each(['1', '10'])('passes a validated limit of %s', async limit => {
34
- await runRefreshHistory('refresh-1', { limit });
35
- expect(getCredentialRefreshHistory).toHaveBeenCalledWith({
36
- refreshId: 'refresh-1',
37
- limit: Number(limit),
38
- });
39
- });
40
-
41
- it('leaves the server default unchanged when omitted', async () => {
42
- await runRefreshHistory('refresh-1', {});
43
- expect(getCredentialRefreshHistory).toHaveBeenCalledWith({ refreshId: 'refresh-1' });
44
- });
45
- });
46
-
47
- describe('formatRefreshVersion', () => {
48
- it('formats version, publishedAt, and summary', () => {
49
- const line = formatRefreshVersion({
50
- version: 2,
51
- publishedAt: '2026-01-01T00:00:00Z',
52
- updateSummary: 'Finalized grades',
53
- });
54
- expect(line).toContain('2');
55
- expect(line).toContain('2026-01-01T00:00:00Z');
56
- expect(line).toContain('Finalized grades');
57
- });
58
-
59
- it('omits the summary column when absent', () => {
60
- const line = formatRefreshVersion({ version: 1, publishedAt: '2026-01-01T00:00:00Z' });
61
- expect(line.trim().endsWith('2026-01-01T00:00:00Z')).toBe(true);
62
- });
63
- });
64
-
65
- describe('mapRefreshHistoryError', () => {
66
- it('rewrites the "not available" failure into an actionable message', () => {
67
- const mapped = mapRefreshHistoryError(
68
- new Error('Credential refresh is not available'),
69
- 'https://network.learncard.com/trpc'
70
- );
71
- expect(mapped.message).toContain("isn't enabled on this network");
72
- expect(mapped.message).toContain('https://network.learncard.com/trpc');
73
- expect(mapped.message).toContain('--network staging');
74
- });
75
-
76
- it('passes other errors through unchanged', () => {
77
- const original = new Error('Refresh not found');
78
- expect(mapRefreshHistoryError(original, 'x')).toBe(original);
79
- });
80
-
81
- it('wraps non-Error throwables', () => {
82
- const mapped = mapRefreshHistoryError('boom', 'x');
83
- expect(mapped).toBeInstanceOf(Error);
84
- expect(mapped.message).toBe('boom');
85
- });
86
- });
package/src/refresh.ts DELETED
@@ -1,93 +0,0 @@
1
- import type { Command } from 'commander';
2
- import type { CredentialRefreshVersionMetadata } from '@learncard/types';
3
-
4
- import { connect, loadProject, resolveServices, type ProjectOptions } from './project';
5
- import { out } from './out';
6
- import { parseLimit } from './inbox';
7
-
8
- export type RefreshHistoryOptions = ProjectOptions & { limit?: string };
9
-
10
- /** One human-readable line per published version: `version publishedAt summary`. */
11
- export const formatRefreshVersion = (record: CredentialRefreshVersionMetadata): string =>
12
- `${String(record.version).padEnd(7)} ${record.publishedAt} ${record.updateSummary ?? ''}`.trimEnd();
13
-
14
- const NOT_AVAILABLE_PATTERN = /credential refresh is not available/i;
15
-
16
- /** Rewrites the network's generic "not available" failure into an actionable message. */
17
- export const mapRefreshHistoryError = (error: unknown, network: string): Error => {
18
- const message = error instanceof Error ? error.message : String(error);
19
- if (NOT_AVAILABLE_PATTERN.test(message)) {
20
- return new Error(
21
- `Credential refresh isn't enabled on this network (${network}). Use --network staging or ask LearnCard to enable it.`
22
- );
23
- }
24
- return error instanceof Error ? error : new Error(message);
25
- };
26
-
27
- export const runRefreshHistory = async (
28
- refreshId: string,
29
- options: RefreshHistoryOptions
30
- ): Promise<void> => {
31
- const limit = options.limit === undefined ? undefined : parseLimit(options.limit);
32
- const project = await loadProject(process.cwd());
33
- const services = resolveServices(project.env, options.network);
34
- const learnCard = await connect(project, options);
35
-
36
- let records: CredentialRefreshVersionMetadata[];
37
- let hasMore: boolean;
38
- try {
39
- const result = await learnCard.invoke.getCredentialRefreshHistory({
40
- refreshId,
41
- ...(limit !== undefined ? { limit } : {}),
42
- });
43
- records = result.records;
44
- hasMore = result.hasMore;
45
- } catch (error) {
46
- throw mapRefreshHistoryError(error, services.network);
47
- }
48
-
49
- if (!records.length) {
50
- out.log(`No published versions found for ${refreshId}.`);
51
- out.set({ refreshId, versions: [], hasMore });
52
- return;
53
- }
54
-
55
- out.log('version publishedAt summary');
56
- for (const record of records) out.log(formatRefreshVersion(record));
57
- if (hasMore) out.log('…more. Raise --limit to see additional versions.');
58
-
59
- out.set({
60
- refreshId,
61
- versions: records.map(record => ({
62
- version: record.version,
63
- publishedAt: record.publishedAt,
64
- updateSummary: record.updateSummary,
65
- })),
66
- hasMore,
67
- });
68
- };
69
-
70
- export const registerRefreshCommand = (
71
- program: Command,
72
- run: (
73
- command: string,
74
- options: { json?: boolean },
75
- action: (didkit: Promise<Buffer>) => Promise<void>,
76
- wrap?: boolean
77
- ) => Promise<void>
78
- ): void => {
79
- const refresh = program
80
- .command('refresh')
81
- .description('Managed credential refresh: audit published versions of a transcript.');
82
- refresh
83
- .command('history <refreshId>')
84
- .description('List the published version history for a managed refresh.')
85
- .option('--limit <n>', 'how many versions to list')
86
- .option('--network <url>', 'network tRPC URL or staging (default: production)')
87
- .option('--json', 'print a single JSON result on stdout')
88
- .action((refreshId, options) =>
89
- run('refresh history', options, async didkit => {
90
- await runRefreshHistory(refreshId, { ...options, didkit });
91
- })
92
- );
93
- };
@@ -1,80 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
-
3
- import type {
4
- ExportLearnCardBundleOptions,
5
- LearnCardBundleWallet,
6
- RestoreLearnCardFromBundleOptions,
7
- } from '@learncard/holder-continuity';
8
- import {
9
- createExportLearnCardBundleHelper,
10
- createRestoreLearnCardFromBundleHelper,
11
- } from './replHelpers';
12
-
13
- const createWallet = (did: string): LearnCardBundleWallet => ({
14
- id: { did: () => did },
15
- invoke: {},
16
- index: { LearnCloud: { get: async () => [], add: async () => true } },
17
- read: { get: async () => undefined },
18
- store: { LearnCloud: {} },
19
- });
20
-
21
- describe('createExportLearnCardBundleHelper', () => {
22
- it('uses the default wallet when only options are provided', async () => {
23
- const defaultWallet = createWallet('did:key:default');
24
- const exportBundle = vi.fn(async () => ({
25
- data: Buffer.from(''),
26
- manifest: {} as never,
27
- warnings: [],
28
- }));
29
- const helper = createExportLearnCardBundleHelper(exportBundle, defaultWallet);
30
- const options: ExportLearnCardBundleOptions = { out: './bundle.zip', password: 'secret' };
31
-
32
- await helper(options);
33
-
34
- expect(exportBundle).toHaveBeenCalledWith(defaultWallet, options);
35
- });
36
-
37
- it('uses the provided wallet when wallet and options are both provided', async () => {
38
- const defaultWallet = createWallet('did:key:default');
39
- const otherWallet = createWallet('did:key:other');
40
- const exportBundle = vi.fn(async () => ({
41
- data: Buffer.from(''),
42
- manifest: {} as never,
43
- warnings: [],
44
- }));
45
- const helper = createExportLearnCardBundleHelper(exportBundle, defaultWallet);
46
- const options: ExportLearnCardBundleOptions = { out: './bundle.zip', password: 'secret' };
47
-
48
- await helper(otherWallet, options);
49
-
50
- expect(exportBundle).toHaveBeenCalledWith(otherWallet, options);
51
- });
52
- });
53
-
54
- describe('createRestoreLearnCardFromBundleHelper', () => {
55
- it('merges default init config with per-call overrides', async () => {
56
- const restoreBundle = vi.fn(async () => ({ restored: true }));
57
- const helper = createRestoreLearnCardFromBundleHelper(restoreBundle, {
58
- network: true,
59
- allowRemoteContexts: true,
60
- didkit: 'node',
61
- });
62
- const options: Omit<RestoreLearnCardFromBundleOptions, 'init'> & {
63
- init?: Partial<RestoreLearnCardFromBundleOptions['init']>;
64
- } = {
65
- password: 'secret',
66
- init: { network: 'http://localhost:4000/trpc' },
67
- };
68
-
69
- await helper('./bundle.zip', options);
70
-
71
- expect(restoreBundle).toHaveBeenCalledWith('./bundle.zip', {
72
- password: 'secret',
73
- init: {
74
- network: 'http://localhost:4000/trpc',
75
- allowRemoteContexts: true,
76
- didkit: 'node',
77
- },
78
- });
79
- });
80
- });
@@ -1,45 +0,0 @@
1
- import type {
2
- ExportLearnCardBundleOptions,
3
- LearnCardBundleWallet,
4
- RestoreLearnCardFromBundleOptions,
5
- RestoreLearnCardInit,
6
- } from '@learncard/holder-continuity';
7
-
8
- type ExportLearnCardBundleFn = typeof import('@learncard/holder-continuity').exportLearnCardBundle;
9
- type RestoreLearnCardFromBundleFn =
10
- typeof import('@learncard/holder-continuity').restoreLearnCardFromBundle;
11
-
12
- export const createExportLearnCardBundleHelper = (
13
- exportBundle: ExportLearnCardBundleFn,
14
- defaultWallet: LearnCardBundleWallet
15
- ) => {
16
- return (
17
- walletOrOptions: LearnCardBundleWallet | ExportLearnCardBundleOptions,
18
- maybeOptions?: ExportLearnCardBundleOptions
19
- ) => {
20
- if (maybeOptions)
21
- return exportBundle(walletOrOptions as LearnCardBundleWallet, maybeOptions);
22
-
23
- return exportBundle(defaultWallet, walletOrOptions as ExportLearnCardBundleOptions);
24
- };
25
- };
26
-
27
- export const createRestoreLearnCardFromBundleHelper = (
28
- restoreBundle: RestoreLearnCardFromBundleFn,
29
- defaultInit: RestoreLearnCardInit
30
- ) => {
31
- return (
32
- path: string,
33
- options: Omit<RestoreLearnCardFromBundleOptions, 'init'> & {
34
- init?: Partial<RestoreLearnCardInit>;
35
- } = {}
36
- ) => {
37
- return restoreBundle(path, {
38
- ...options,
39
- init: {
40
- ...defaultInit,
41
- ...(options.init ?? {}),
42
- } as RestoreLearnCardInit,
43
- });
44
- };
45
- };
@@ -1,29 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { recipientOf, templateUriOf } from './revoke';
3
-
4
- describe('revoke targeting', () => {
5
- it('reads the template URI from the credential boostId', () => {
6
- expect(templateUriOf({ boostId: 'lc:network:x/trpc:boost:1' }, {})).toBe(
7
- 'lc:network:x/trpc:boost:1'
8
- );
9
- });
10
-
11
- it('prefers an explicit --template-uri', () => {
12
- expect(templateUriOf({ boostId: 'lc:boost:a' }, { templateUri: 'lc:boost:b' })).toBe(
13
- 'lc:boost:b'
14
- );
15
- });
16
-
17
- it('explains when a credential did not come from a template', () => {
18
- expect(() => templateUriOf({ id: 'urn:uuid:1' }, {})).toThrow(/not issued from a template/);
19
- });
20
-
21
- it('finds the recipient holding the exact credential URI', () => {
22
- const records = [
23
- { to: { profileId: 'alice' }, uri: 'lc:network:x/trpc:credential:1' },
24
- { to: { profileId: 'bob' }, uri: 'lc:network:x/trpc:credential:2' },
25
- ];
26
- expect(recipientOf(records, 'lc:network:x/trpc:credential:2')).toBe('bob');
27
- expect(recipientOf(records, 'lc:network:x/trpc:credential:9')).toBeUndefined();
28
- });
29
- });
package/src/revoke.ts DELETED
@@ -1,65 +0,0 @@
1
- import { connect, ensureIdentity, loadProject, type ProjectOptions } from './project';
2
- import { out } from './out';
3
-
4
- export interface RevokeOptions extends ProjectOptions {
5
- suspend?: boolean;
6
- templateUri?: string;
7
- recipient?: string;
8
- }
9
-
10
- /** Template URI comes from the credential's `boostId`; the recipient from the network's recipient list. */
11
- export const templateUriOf = (credential: unknown, options: RevokeOptions): string => {
12
- const value =
13
- credential && typeof credential === 'object' ? (credential as Record<string, unknown>) : {};
14
- const templateUri =
15
- options.templateUri || (typeof value.boostId === 'string' ? value.boostId : undefined);
16
- if (!templateUri)
17
- throw new Error(
18
- 'This credential was not issued from a template. Pass --template-uri <uri> and --recipient <profileId>.'
19
- );
20
- return templateUri;
21
- };
22
-
23
- type Recipient = { to: { profileId: string }; uri?: string };
24
-
25
- /** Find which recipient of a template holds this exact credential URI. */
26
- export const recipientOf = (records: Recipient[], credentialUri: string): string | undefined =>
27
- records.find(record => record.uri === credentialUri)?.to.profileId;
28
-
29
- export const runRevoke = async (uri: string, options: RevokeOptions): Promise<void> => {
30
- const project = await loadProject(process.cwd());
31
- await ensureIdentity(project, options);
32
- const learnCard = await connect(project, options);
33
- const credential =
34
- options.templateUri && options.recipient ? undefined : await learnCard.read.get(uri);
35
- const templateUri = templateUriOf(credential, options);
36
- let profileId = options.recipient;
37
- let cursor: string | undefined;
38
- while (!profileId) {
39
- const page = await learnCard.invoke.getPaginatedBoostRecipients(
40
- templateUri,
41
- 100,
42
- cursor,
43
- true
44
- );
45
- profileId = recipientOf(page.records, uri);
46
- if (profileId || !page.hasMore) break;
47
- cursor = page.cursor ?? undefined;
48
- }
49
- if (!profileId)
50
- throw new Error(
51
- `No recipient of ${templateUri} holds ${uri}. If it was sent to an email and not yet claimed, there is nothing to revoke; otherwise pass --recipient <profileId>.`
52
- );
53
- const target = { templateUri, profileId };
54
- const result = options.suspend
55
- ? await learnCard.invoke.suspendBoostRecipient(target.templateUri, target.profileId, uri)
56
- : await learnCard.invoke.revokeBoostRecipient(target.templateUri, target.profileId, uri);
57
- if (!result) throw new Error('The network did not update this credential.');
58
- const status = options.suspend ? 'suspended' : 'revoked';
59
- out.log(
60
- `${options.suspend ? 'Suspended' : 'Revoked'} ${uri}. Verifiers will see status: ${status} when they next refresh its status list; no propagation interval is documented.`
61
- );
62
- out.log('Only credentials with a credentialStatus entry support status-list verification.');
63
- out.log('Check the delivered JSON: npx @learncard/cli verify credential.json');
64
- out.set({ credentialUri: uri, templateUri, recipient: profileId, action: status });
65
- };
@@ -1,20 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { templateCredential, personalizeSendMjs } from './send';
3
- import { SEND_FROM_TEMPLATE_MJS } from './generated/snippets';
4
-
5
- describe('template sending', () => {
6
- it('personalizes replacement metacharacters literally', () => {
7
- const output = personalizeSendMjs('$&', { name: '$&', description: '$&' });
8
- expect(output).toContain('displayName: "$&"');
9
- expect(output).toContain('description: "$&"');
10
- });
11
- it('includes Boost terms needed when the network stamps boostId', () => {
12
- const credential = templateCredential('did:key:issuer');
13
- expect(credential['@context']).toContain('https://ctx.learncard.com/boosts/1.0.1.json');
14
- expect(credential.type).toContain('BoostCredential');
15
- });
16
- it('reuses the persisted template rather than creating another one', () => {
17
- expect(SEND_FROM_TEMPLATE_MJS).toContain('templateUri: process.env.TEMPLATE_URI');
18
- expect(SEND_FROM_TEMPLATE_MJS).not.toContain('createBoost');
19
- });
20
- });