@learncard/cli 3.6.7 → 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.
- package/CHANGELOG.md +79 -0
- package/README.md +36 -12
- package/dist/index.js +6673 -2038
- package/package.json +27 -15
- package/project.json +0 -42
- package/rollup.config.js +0 -34
- package/scripts/embed-snippets.ts +0 -47
- package/src/auth-grant.test.ts +0 -54
- package/src/auth-grant.ts +0 -34
- package/src/clr/validate.test.ts +0 -65
- package/src/clr/validate.ts +0 -242
- package/src/clr.ts +0 -119
- package/src/consent-contract.ts +0 -116
- package/src/demo-inbox-refresh.test.ts +0 -737
- package/src/demo-inbox-refresh.ts +0 -804
- package/src/demo-refresh-command.test.ts +0 -57
- package/src/demo-refresh-command.ts +0 -22
- package/src/demo-refresh-ui.test.ts +0 -66
- package/src/demo-refresh-ui.ts +0 -65
- package/src/demo-refresh.test.ts +0 -140
- package/src/demo-refresh.ts +0 -309
- package/src/doctor/checks.test.ts +0 -448
- package/src/doctor/checks.ts +0 -497
- package/src/doctor.test.ts +0 -67
- package/src/doctor.ts +0 -118
- package/src/embed.ts +0 -102
- package/src/inbox.test.ts +0 -257
- package/src/inbox.ts +0 -221
- package/src/index.tsx +0 -876
- package/src/init.ts +0 -26
- package/src/open.test.ts +0 -40
- package/src/open.ts +0 -186
- package/src/org/apply.test.ts +0 -1108
- package/src/org/apply.ts +0 -924
- package/src/org/branding.test.ts +0 -60
- package/src/org/diff.ts +0 -14
- package/src/org/load.ts +0 -50
- package/src/org/schema.test.ts +0 -256
- package/src/org/schema.ts +0 -216
- package/src/org.ts +0 -124
- package/src/out.test.ts +0 -40
- package/src/out.ts +0 -16
- package/src/phase-two.test.ts +0 -154
- package/src/project.test.ts +0 -232
- package/src/project.ts +0 -489
- package/src/promote.test.ts +0 -142
- package/src/promote.ts +0 -202
- package/src/random.ts +0 -3
- package/src/refresh.test.ts +0 -86
- package/src/refresh.ts +0 -93
- package/src/replHelpers.test.ts +0 -80
- package/src/replHelpers.ts +0 -45
- package/src/revoke.test.ts +0 -29
- package/src/revoke.ts +0 -65
- package/src/send-template.test.ts +0 -20
- package/src/send.test.ts +0 -321
- package/src/send.ts +0 -373
- package/src/setup-signing.test.ts +0 -62
- package/src/setup-signing.ts +0 -185
- package/src/snippet-files.ts +0 -16
- package/src/status.test.ts +0 -33
- package/src/status.ts +0 -94
- package/src/token.test.ts +0 -28
- package/src/token.ts +0 -139
- package/src/types.d.ts +0 -17
- package/src/verify.test.ts +0 -35
- package/src/verify.ts +0 -66
- package/src/webhook.ts +0 -247
- package/src/whoami.test.ts +0 -67
- package/src/whoami.ts +0 -129
- package/tsconfig.json +0 -39
- 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
package/src/refresh.test.ts
DELETED
|
@@ -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
|
-
};
|
package/src/replHelpers.test.ts
DELETED
|
@@ -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
|
-
});
|
package/src/replHelpers.ts
DELETED
|
@@ -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
|
-
};
|
package/src/revoke.test.ts
DELETED
|
@@ -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
|
-
});
|