@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/embed.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { LCNDomainOrOriginValidator } from '@learncard/types';
|
|
2
|
-
import {
|
|
3
|
-
connect,
|
|
4
|
-
ensureIdentity,
|
|
5
|
-
ensureProfile,
|
|
6
|
-
KEYS,
|
|
7
|
-
loadProject,
|
|
8
|
-
resolveServices,
|
|
9
|
-
saveProject,
|
|
10
|
-
type ProjectOptions,
|
|
11
|
-
} from './project';
|
|
12
|
-
import { setupSigning } from './setup-signing';
|
|
13
|
-
import { CLAIM_BUTTON_HTML } from './generated/snippets';
|
|
14
|
-
import { writeSnippet } from './snippet-files';
|
|
15
|
-
import { out } from './out';
|
|
16
|
-
|
|
17
|
-
export const validateDomains = (domains: string): string[] =>
|
|
18
|
-
domains.split(',').map(domain => {
|
|
19
|
-
const result = LCNDomainOrOriginValidator.safeParse(domain.trim());
|
|
20
|
-
if (!result.success)
|
|
21
|
-
throw new Error(result.error.format()._errors.join('; ') || result.error.message);
|
|
22
|
-
return result.data;
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
const scriptString = (value: string): string => JSON.stringify(value).replace(/</g, '\\u003c');
|
|
26
|
-
|
|
27
|
-
export const personalizeClaimButton = (key: string, apiBaseUrl: string): string =>
|
|
28
|
-
CLAIM_BUTTON_HTML.replace("'PUBLISHABLE_KEY_PLACEHOLDER'", () => scriptString(key)).replace(
|
|
29
|
-
"'https://network.learncard.com/api'",
|
|
30
|
-
() => scriptString(apiBaseUrl)
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
export const runEmbed = async (
|
|
34
|
-
options: ProjectOptions & { domains?: string; rotateKey?: boolean }
|
|
35
|
-
): Promise<void> => {
|
|
36
|
-
const domains = validateDomains(
|
|
37
|
-
options.domains ?? 'http://localhost:3000,http://localhost:5173'
|
|
38
|
-
);
|
|
39
|
-
const project = await loadProject(process.cwd());
|
|
40
|
-
const identity = await ensureIdentity(project, options);
|
|
41
|
-
const learnCard = await connect(project, { ...options, lca: true });
|
|
42
|
-
await ensureProfile(learnCard, identity, project);
|
|
43
|
-
await setupSigning(project, learnCard);
|
|
44
|
-
let id = project.env[KEYS.INTEGRATION_ID];
|
|
45
|
-
const reused = Boolean(id);
|
|
46
|
-
if (!id) {
|
|
47
|
-
id = await learnCard.invoke.addIntegration({
|
|
48
|
-
name: options.name ?? identity.displayName,
|
|
49
|
-
whitelistedDomains: domains,
|
|
50
|
-
guideType: 'embed-claim',
|
|
51
|
-
});
|
|
52
|
-
// Persist immediately so a later failed read/rotation never duplicates the integration.
|
|
53
|
-
await saveProject(project, { [KEYS.INTEGRATION_ID]: id });
|
|
54
|
-
}
|
|
55
|
-
let integration = await learnCard.invoke.getIntegration(id);
|
|
56
|
-
if (!integration) throw new Error('Saved integration was not found. Check INTEGRATION_ID.');
|
|
57
|
-
if (options.rotateKey) {
|
|
58
|
-
if (!(await learnCard.invoke.updateIntegration(id, { rotatePublishableKey: true }))) {
|
|
59
|
-
throw new Error('Could not rotate the publishable key.');
|
|
60
|
-
}
|
|
61
|
-
integration = await learnCard.invoke.getIntegration(id);
|
|
62
|
-
if (!integration) throw new Error('Could not read the rotated integration.');
|
|
63
|
-
}
|
|
64
|
-
await saveProject(project, {
|
|
65
|
-
[KEYS.INTEGRATION_ID]: id,
|
|
66
|
-
[KEYS.PUBLISHABLE_KEY]: integration.publishableKey,
|
|
67
|
-
});
|
|
68
|
-
const apiBase = resolveServices(project.env, options.network).network.replace(
|
|
69
|
-
/\/trpc\/?$/,
|
|
70
|
-
'/api'
|
|
71
|
-
);
|
|
72
|
-
const wroteClaimButton = await writeSnippet(
|
|
73
|
-
'claim-button.html',
|
|
74
|
-
personalizeClaimButton(integration.publishableKey, apiBase)
|
|
75
|
-
);
|
|
76
|
-
out.log(`Publishable key: ${integration.publishableKey}`);
|
|
77
|
-
out.log(`Whitelisted origins: ${integration.whitelistedDomains.join(', ')}`);
|
|
78
|
-
if (options.domains && reused) {
|
|
79
|
-
out.log(
|
|
80
|
-
'Existing integrations keep their saved origins; change them in the Developer Portal.'
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
if (options.rotateKey)
|
|
84
|
-
out.log('Update the publishable key in any existing claim-button.html and deployed pages.');
|
|
85
|
-
const firstDomain = integration.whitelistedDomains[0];
|
|
86
|
-
const origin = firstDomain?.startsWith('http')
|
|
87
|
-
? firstDomain
|
|
88
|
-
: firstDomain
|
|
89
|
-
? `http://${firstDomain}`
|
|
90
|
-
: undefined;
|
|
91
|
-
out.log(
|
|
92
|
-
origin
|
|
93
|
-
? `Next: Open claim-button.html from ${origin} (a whitelisted origin), not file://.\nSee the integration in the app: npx @learncard/cli open integration`
|
|
94
|
-
: 'Next: add a whitelisted origin in the Developer Portal, then serve claim-button.html there, not file://.'
|
|
95
|
-
);
|
|
96
|
-
out.set({
|
|
97
|
-
integrationId: id,
|
|
98
|
-
publishableKey: integration.publishableKey,
|
|
99
|
-
whitelistedDomains: integration.whitelistedDomains,
|
|
100
|
-
files: wroteClaimButton ? ['./claim-button.html'] : [],
|
|
101
|
-
});
|
|
102
|
-
};
|
package/src/inbox.test.ts
DELETED
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { Command } from 'commander';
|
|
3
|
-
import {
|
|
4
|
-
fetchSentInboxCredentials,
|
|
5
|
-
filterSince,
|
|
6
|
-
parseLimit,
|
|
7
|
-
parseSince,
|
|
8
|
-
registerInboxCommand,
|
|
9
|
-
toJsonRecord,
|
|
10
|
-
type SentInboxRecord,
|
|
11
|
-
} from './inbox';
|
|
12
|
-
|
|
13
|
-
const baseRecord = (overrides: Partial<SentInboxRecord> = {}): SentInboxRecord => ({
|
|
14
|
-
id: 'inbox-1',
|
|
15
|
-
isSigned: true,
|
|
16
|
-
currentStatus: 'PENDING',
|
|
17
|
-
expiresAt: '2026-02-01T00:00:00.000Z',
|
|
18
|
-
createdAt: '2026-01-01T00:00:00.000Z',
|
|
19
|
-
issuerDid: 'did:example:issuer',
|
|
20
|
-
...overrides,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
describe('parseLimit', () => {
|
|
24
|
-
it('defaults to 50', () => {
|
|
25
|
-
expect(parseLimit(undefined)).toBe(50);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('accepts positive integers', () => {
|
|
29
|
-
expect(parseLimit('10')).toBe(10);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it.each(['abc', '0', '-1', '1.5'])('rejects %s', value => {
|
|
33
|
-
expect(() => parseLimit(value)).toThrow('--limit');
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
describe('parseSince', () => {
|
|
38
|
-
const now = new Date('2026-01-10T00:00:00.000Z');
|
|
39
|
-
|
|
40
|
-
it('parses day durations', () => {
|
|
41
|
-
expect(parseSince('7d', now).toISOString()).toBe('2026-01-03T00:00:00.000Z');
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('parses hour durations', () => {
|
|
45
|
-
expect(parseSince('24h', now).toISOString()).toBe('2026-01-09T00:00:00.000Z');
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('parses minute durations', () => {
|
|
49
|
-
expect(parseSince('30m', now).toISOString()).toBe('2026-01-09T23:30:00.000Z');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('parses an ISO timestamp', () => {
|
|
53
|
-
expect(parseSince('2026-01-05T00:00:00.000Z', now).toISOString()).toBe(
|
|
54
|
-
'2026-01-05T00:00:00.000Z'
|
|
55
|
-
);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('throws on garbage input', () => {
|
|
59
|
-
expect(() => parseSince('not-a-duration', now)).toThrow(/Invalid --since/);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('filterSince', () => {
|
|
64
|
-
it('keeps only records at or after the cutoff', () => {
|
|
65
|
-
const records = [
|
|
66
|
-
baseRecord({ id: 'old', createdAt: '2026-01-01T00:00:00.000Z' }),
|
|
67
|
-
baseRecord({ id: 'new', createdAt: '2026-01-09T00:00:00.000Z' }),
|
|
68
|
-
];
|
|
69
|
-
expect(filterSince(records, new Date('2026-01-05T00:00:00.000Z')).map(r => r.id)).toEqual([
|
|
70
|
-
'new',
|
|
71
|
-
]);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('is a no-op without a cutoff', () => {
|
|
75
|
-
const records = [baseRecord()];
|
|
76
|
-
expect(filterSince(records, undefined)).toBe(records);
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe('inbox command registration', () => {
|
|
81
|
-
it('does not advertise or accept the unsupported recipient-type filter', async () => {
|
|
82
|
-
const program = new Command().exitOverride().configureOutput({ writeErr: () => {} });
|
|
83
|
-
const run = vi.fn();
|
|
84
|
-
registerInboxCommand(program, run);
|
|
85
|
-
const list = program.commands[0]!.commands[0]!;
|
|
86
|
-
expect(list.helpInformation()).not.toContain('--recipient-type');
|
|
87
|
-
await expect(
|
|
88
|
-
program.parseAsync(['inbox', 'list', '--recipient-type', 'email'], { from: 'user' })
|
|
89
|
-
).rejects.toThrow("unknown option '--recipient-type'");
|
|
90
|
-
expect(run).not.toHaveBeenCalled();
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe('fetchSentInboxCredentials', () => {
|
|
95
|
-
it.each([false, true])(
|
|
96
|
-
'reports overflow even when server hasMore is %s',
|
|
97
|
-
async serverHasMore => {
|
|
98
|
-
const getMySentInboxCredentials = vi.fn().mockResolvedValue({
|
|
99
|
-
records: [baseRecord({ id: '1' }), baseRecord({ id: '2' })],
|
|
100
|
-
hasMore: serverHasMore,
|
|
101
|
-
});
|
|
102
|
-
const result = await fetchSentInboxCredentials(
|
|
103
|
-
{ getMySentInboxCredentials },
|
|
104
|
-
{ limit: 1 }
|
|
105
|
-
);
|
|
106
|
-
expect(result.records.map(record => record.id)).toEqual(['1']);
|
|
107
|
-
expect(result.hasMore).toBe(true);
|
|
108
|
-
expect(getMySentInboxCredentials).toHaveBeenCalledExactlyOnceWith({ limit: 1 });
|
|
109
|
-
}
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
it('requests the last remaining record and stops at an exact limit', async () => {
|
|
113
|
-
const getMySentInboxCredentials = vi
|
|
114
|
-
.fn()
|
|
115
|
-
.mockResolvedValueOnce({ records: [baseRecord()], hasMore: true, cursor: 'next' })
|
|
116
|
-
.mockResolvedValueOnce({ records: [baseRecord({ id: '2' })], hasMore: false });
|
|
117
|
-
const result = await fetchSentInboxCredentials({ getMySentInboxCredentials }, { limit: 2 });
|
|
118
|
-
expect(getMySentInboxCredentials).toHaveBeenNthCalledWith(2, { limit: 1, cursor: 'next' });
|
|
119
|
-
expect(getMySentInboxCredentials).toHaveBeenCalledTimes(2);
|
|
120
|
-
expect(result.records).toHaveLength(2);
|
|
121
|
-
expect(result.hasMore).toBe(false);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('merges cursor-paginated pages up to the requested limit', async () => {
|
|
125
|
-
const page1 = {
|
|
126
|
-
hasMore: true,
|
|
127
|
-
cursor: 'cursor-1',
|
|
128
|
-
records: [baseRecord({ id: '1' }), baseRecord({ id: '2' })],
|
|
129
|
-
};
|
|
130
|
-
const page2 = { hasMore: false, records: [baseRecord({ id: '3' })] };
|
|
131
|
-
const getMySentInboxCredentials = vi
|
|
132
|
-
.fn()
|
|
133
|
-
.mockResolvedValueOnce(page1)
|
|
134
|
-
.mockResolvedValueOnce(page2);
|
|
135
|
-
|
|
136
|
-
const { records, hasMore } = await fetchSentInboxCredentials(
|
|
137
|
-
{ getMySentInboxCredentials },
|
|
138
|
-
{ limit: 10 }
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
expect(records.map(r => r.id)).toEqual(['1', '2', '3']);
|
|
142
|
-
expect(hasMore).toBe(false);
|
|
143
|
-
expect(getMySentInboxCredentials).toHaveBeenCalledTimes(2);
|
|
144
|
-
expect(getMySentInboxCredentials).toHaveBeenNthCalledWith(
|
|
145
|
-
2,
|
|
146
|
-
expect.objectContaining({ cursor: 'cursor-1', limit: 8 })
|
|
147
|
-
);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('stops once the requested limit is reached, reporting more remain', async () => {
|
|
151
|
-
const page1 = {
|
|
152
|
-
hasMore: true,
|
|
153
|
-
cursor: 'cursor-1',
|
|
154
|
-
records: [baseRecord({ id: '1' }), baseRecord({ id: '2' })],
|
|
155
|
-
};
|
|
156
|
-
const getMySentInboxCredentials = vi.fn().mockResolvedValueOnce(page1);
|
|
157
|
-
|
|
158
|
-
const { records, hasMore } = await fetchSentInboxCredentials(
|
|
159
|
-
{ getMySentInboxCredentials },
|
|
160
|
-
{ limit: 1 }
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
expect(records.map(r => r.id)).toEqual(['1']);
|
|
164
|
-
expect(hasMore).toBe(true);
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
describe('toJsonRecord', () => {
|
|
169
|
-
it('shapes a record for --json output, omitting an absent recipient', () => {
|
|
170
|
-
const record = baseRecord({ id: 'x' });
|
|
171
|
-
expect(toJsonRecord(record)).toEqual({
|
|
172
|
-
id: 'x',
|
|
173
|
-
status: 'PENDING',
|
|
174
|
-
recipient: undefined,
|
|
175
|
-
createdAt: record.createdAt,
|
|
176
|
-
expiresAt: record.expiresAt,
|
|
177
|
-
credentialName: undefined,
|
|
178
|
-
isSigned: true,
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('includes the recipient value only when present', () => {
|
|
183
|
-
const withValue = baseRecord({ recipient: { type: 'email', value: 'a@example.com' } });
|
|
184
|
-
expect(toJsonRecord(withValue).recipient).toEqual({
|
|
185
|
-
type: 'email',
|
|
186
|
-
value: 'a@example.com',
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
const withoutValue = baseRecord({ recipient: { type: 'phone' } });
|
|
190
|
-
expect(toJsonRecord(withoutValue).recipient).toEqual({ type: 'phone' });
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
describe('inbox list pipeline', () => {
|
|
195
|
-
it('merges two mocked pages, filters by since, and shapes json records', async () => {
|
|
196
|
-
const page1 = {
|
|
197
|
-
hasMore: true,
|
|
198
|
-
cursor: 'c1',
|
|
199
|
-
records: [
|
|
200
|
-
baseRecord({
|
|
201
|
-
id: '1',
|
|
202
|
-
createdAt: '2026-01-01T00:00:00.000Z',
|
|
203
|
-
recipient: { type: 'email', value: 'a@example.com' },
|
|
204
|
-
}),
|
|
205
|
-
baseRecord({
|
|
206
|
-
id: '2',
|
|
207
|
-
createdAt: '2026-01-08T00:00:00.000Z',
|
|
208
|
-
recipient: { type: 'phone' },
|
|
209
|
-
}),
|
|
210
|
-
],
|
|
211
|
-
};
|
|
212
|
-
const page2 = {
|
|
213
|
-
hasMore: false,
|
|
214
|
-
records: [
|
|
215
|
-
baseRecord({
|
|
216
|
-
id: '3',
|
|
217
|
-
createdAt: '2026-01-09T00:00:00.000Z',
|
|
218
|
-
recipient: { type: 'email' },
|
|
219
|
-
}),
|
|
220
|
-
],
|
|
221
|
-
};
|
|
222
|
-
const getMySentInboxCredentials = vi
|
|
223
|
-
.fn()
|
|
224
|
-
.mockResolvedValueOnce(page1)
|
|
225
|
-
.mockResolvedValueOnce(page2);
|
|
226
|
-
|
|
227
|
-
const { records, hasMore } = await fetchSentInboxCredentials(
|
|
228
|
-
{ getMySentInboxCredentials },
|
|
229
|
-
{ limit: 10 }
|
|
230
|
-
);
|
|
231
|
-
const since = parseSince('7d', new Date('2026-01-10T00:00:00.000Z'));
|
|
232
|
-
const filtered = filterSince(records, since);
|
|
233
|
-
|
|
234
|
-
expect(filtered.map(r => r.id)).toEqual(['2', '3']);
|
|
235
|
-
expect(hasMore).toBe(false);
|
|
236
|
-
expect(filtered.map(toJsonRecord)).toEqual([
|
|
237
|
-
{
|
|
238
|
-
id: '2',
|
|
239
|
-
status: 'PENDING',
|
|
240
|
-
recipient: { type: 'phone' },
|
|
241
|
-
createdAt: '2026-01-08T00:00:00.000Z',
|
|
242
|
-
expiresAt: page1.records[1]!.expiresAt,
|
|
243
|
-
credentialName: undefined,
|
|
244
|
-
isSigned: true,
|
|
245
|
-
},
|
|
246
|
-
{
|
|
247
|
-
id: '3',
|
|
248
|
-
status: 'PENDING',
|
|
249
|
-
recipient: { type: 'email' },
|
|
250
|
-
createdAt: '2026-01-09T00:00:00.000Z',
|
|
251
|
-
expiresAt: page2.records[0]!.expiresAt,
|
|
252
|
-
credentialName: undefined,
|
|
253
|
-
isSigned: true,
|
|
254
|
-
},
|
|
255
|
-
]);
|
|
256
|
-
});
|
|
257
|
-
});
|
package/src/inbox.ts
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
import { Option, type Command } from 'commander';
|
|
2
|
-
import type {
|
|
3
|
-
InboxCredentialQuery,
|
|
4
|
-
InboxCredentialType,
|
|
5
|
-
PaginationOptionsType,
|
|
6
|
-
} from '@learncard/types';
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
connect,
|
|
10
|
-
connectAsManaged,
|
|
11
|
-
loadProject,
|
|
12
|
-
type NetworkCard,
|
|
13
|
-
type ProjectOptions,
|
|
14
|
-
} from './project';
|
|
15
|
-
import { out } from './out';
|
|
16
|
-
|
|
17
|
-
export interface InboxRecipient {
|
|
18
|
-
type?: string;
|
|
19
|
-
value?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The network's current inbox listing carries no recipient contact info per record
|
|
24
|
-
* (only the send-time response does). `recipient` is modeled as optional so this type
|
|
25
|
-
* stays compatible with today's API.
|
|
26
|
-
*/
|
|
27
|
-
export type SentInboxRecord = InboxCredentialType & { recipient?: InboxRecipient };
|
|
28
|
-
|
|
29
|
-
export interface InboxJsonRecord {
|
|
30
|
-
id: string;
|
|
31
|
-
status: string;
|
|
32
|
-
recipient?: { type?: string; value?: string };
|
|
33
|
-
createdAt: string;
|
|
34
|
-
expiresAt: string;
|
|
35
|
-
credentialName?: string;
|
|
36
|
-
isSigned: boolean;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const STATUS_VALUES = ['PENDING', 'ISSUED', 'EXPIRED', 'DELIVERED', 'CLAIMED'] as const;
|
|
40
|
-
type InboxStatus = (typeof STATUS_VALUES)[number];
|
|
41
|
-
|
|
42
|
-
export const isInboxStatus = (value: string): value is InboxStatus =>
|
|
43
|
-
(STATUS_VALUES as readonly string[]).includes(value);
|
|
44
|
-
|
|
45
|
-
export const parseStatus = (value: string | undefined): InboxStatus | undefined => {
|
|
46
|
-
if (value === undefined) return undefined;
|
|
47
|
-
const upper = value.toUpperCase();
|
|
48
|
-
if (!isInboxStatus(upper))
|
|
49
|
-
throw new Error(
|
|
50
|
-
`Unknown --status "${value}". Use PENDING, ISSUED, or EXPIRED (DELIVERED and CLAIMED are deprecated aliases).`
|
|
51
|
-
);
|
|
52
|
-
return upper;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
export const parseLimit = (value: string | undefined): number => {
|
|
56
|
-
if (value === undefined) return 50;
|
|
57
|
-
const limit = Number(value);
|
|
58
|
-
if (!Number.isInteger(limit) || limit < 1)
|
|
59
|
-
throw new Error(`Invalid --limit "${value}". Use a positive whole number.`);
|
|
60
|
-
return limit;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const DURATION_PATTERN = /^(\d+)\s*(d|h|m)$/i;
|
|
64
|
-
const MS_PER_UNIT = { d: 86_400_000, h: 3_600_000, m: 60_000 } as const;
|
|
65
|
-
|
|
66
|
-
/** Parses `7d` / `24h` / `30m` (relative to `now`) or an ISO timestamp into a cutoff Date. */
|
|
67
|
-
export const parseSince = (value: string, now: Date = new Date()): Date => {
|
|
68
|
-
const match = value.match(DURATION_PATTERN);
|
|
69
|
-
if (match) {
|
|
70
|
-
const amount = Number(match[1]);
|
|
71
|
-
const unit = match[2]!.toLowerCase() as keyof typeof MS_PER_UNIT;
|
|
72
|
-
return new Date(now.getTime() - amount * MS_PER_UNIT[unit]);
|
|
73
|
-
}
|
|
74
|
-
const parsed = new Date(value);
|
|
75
|
-
if (Number.isNaN(parsed.getTime()))
|
|
76
|
-
throw new Error(`Invalid --since value "${value}". Use 7d, 24h, 30m, or an ISO timestamp.`);
|
|
77
|
-
return parsed;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
/** Keeps only records created at or after `since`; a no-op when `since` is undefined. */
|
|
81
|
-
export const filterSince = <T extends { createdAt: string }>(records: T[], since?: Date): T[] => {
|
|
82
|
-
if (!since) return records;
|
|
83
|
-
const cutoff = since.getTime();
|
|
84
|
-
return records.filter(record => new Date(record.createdAt).getTime() >= cutoff);
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
export interface FetchSentInboxOptions {
|
|
88
|
-
limit: number;
|
|
89
|
-
currentStatus?: InboxStatus;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface FetchSentInboxResult {
|
|
93
|
-
records: SentInboxRecord[];
|
|
94
|
-
hasMore: boolean;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Cursor-paginates the network's sent-inbox listing until `hasMore` is false or `limit` is reached. */
|
|
98
|
-
export const fetchSentInboxCredentials = async (
|
|
99
|
-
card: Pick<NetworkCard['invoke'], 'getMySentInboxCredentials'>,
|
|
100
|
-
options: FetchSentInboxOptions
|
|
101
|
-
): Promise<FetchSentInboxResult> => {
|
|
102
|
-
const records: SentInboxRecord[] = [];
|
|
103
|
-
let cursor: string | undefined;
|
|
104
|
-
let serverHasMore = true;
|
|
105
|
-
|
|
106
|
-
while (serverHasMore && records.length < options.limit) {
|
|
107
|
-
const query: Partial<PaginationOptionsType> & { query?: InboxCredentialQuery } = {
|
|
108
|
-
limit: Math.max(1, options.limit - records.length),
|
|
109
|
-
...(cursor ? { cursor } : {}),
|
|
110
|
-
...(options.currentStatus ? { query: { currentStatus: options.currentStatus } } : {}),
|
|
111
|
-
};
|
|
112
|
-
const page = await card.getMySentInboxCredentials(query);
|
|
113
|
-
records.push(...page.records);
|
|
114
|
-
serverHasMore = page.hasMore;
|
|
115
|
-
cursor = page.cursor;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const total = records.length;
|
|
119
|
-
return {
|
|
120
|
-
records: records.slice(0, options.limit),
|
|
121
|
-
hasMore: serverHasMore || total > options.limit,
|
|
122
|
-
};
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
/** Shapes a record for `--json` output; `recipient` is omitted when the metadata lacks it. */
|
|
126
|
-
export const toJsonRecord = (record: SentInboxRecord): InboxJsonRecord => ({
|
|
127
|
-
id: record.id,
|
|
128
|
-
status: record.currentStatus,
|
|
129
|
-
recipient: record.recipient
|
|
130
|
-
? {
|
|
131
|
-
type: record.recipient.type,
|
|
132
|
-
...(record.recipient.value ? { value: record.recipient.value } : {}),
|
|
133
|
-
}
|
|
134
|
-
: undefined,
|
|
135
|
-
createdAt: record.createdAt,
|
|
136
|
-
expiresAt: record.expiresAt,
|
|
137
|
-
credentialName: record.credentialName,
|
|
138
|
-
isSigned: record.isSigned,
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
const formatRow = (record: SentInboxRecord): string => {
|
|
142
|
-
const created = record.createdAt.replace('T', ' ').slice(0, 19);
|
|
143
|
-
const recipient = record.recipient
|
|
144
|
-
? `${record.recipient.type ?? '?'}${record.recipient.value ? `:${record.recipient.value}` : ''}`
|
|
145
|
-
: '—';
|
|
146
|
-
return `${record.id} ${record.currentStatus.padEnd(8)} ${recipient} ${created} ${record.credentialName ?? ''}`;
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
export type InboxListOptions = ProjectOptions & {
|
|
150
|
-
as?: string;
|
|
151
|
-
status?: string;
|
|
152
|
-
since?: string;
|
|
153
|
-
|
|
154
|
-
limit?: string;
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
export const runInboxList = async (options: InboxListOptions): Promise<void> => {
|
|
158
|
-
const project = await loadProject(process.cwd());
|
|
159
|
-
const learnCard = options.as
|
|
160
|
-
? await connectAsManaged(project, options, options.as)
|
|
161
|
-
: await connect(project, options);
|
|
162
|
-
|
|
163
|
-
const currentStatus = parseStatus(options.status);
|
|
164
|
-
const limit = parseLimit(options.limit);
|
|
165
|
-
const since = options.since ? parseSince(options.since) : undefined;
|
|
166
|
-
|
|
167
|
-
const { records: fetched, hasMore } = await fetchSentInboxCredentials(learnCard.invoke, {
|
|
168
|
-
limit,
|
|
169
|
-
currentStatus,
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
const filtered = filterSince(fetched, since);
|
|
173
|
-
|
|
174
|
-
if (!filtered.length) {
|
|
175
|
-
out.log('No sent inbox credentials match. Try widening --since or dropping --status.');
|
|
176
|
-
out.set({ records: [], hasMore });
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
out.log('issuanceId status recipient created name');
|
|
181
|
-
for (const record of filtered) out.log(formatRow(record));
|
|
182
|
-
if (hasMore) out.log('…more. Raise --limit to see additional sends.');
|
|
183
|
-
|
|
184
|
-
out.set({ records: filtered.map(toJsonRecord), hasMore });
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
export const registerInboxCommand = (
|
|
188
|
-
program: Command,
|
|
189
|
-
run: (
|
|
190
|
-
command: string,
|
|
191
|
-
options: { json?: boolean },
|
|
192
|
-
action: (didkit: Promise<Buffer>) => Promise<void>,
|
|
193
|
-
wrap?: boolean
|
|
194
|
-
) => Promise<void>
|
|
195
|
-
): void => {
|
|
196
|
-
const inbox = program
|
|
197
|
-
.command('inbox')
|
|
198
|
-
.description('Credentials sent through the inbox (email/phone claim flow).');
|
|
199
|
-
inbox
|
|
200
|
-
.command('list')
|
|
201
|
-
.description('List credentials you sent through the inbox.')
|
|
202
|
-
.addOption(
|
|
203
|
-
new Option('--as <profileId>', 'list credentials sent by a profile you manage').env(
|
|
204
|
-
'LEARNCARD_AS'
|
|
205
|
-
)
|
|
206
|
-
)
|
|
207
|
-
.option('--status <status>', 'PENDING, ISSUED, or EXPIRED')
|
|
208
|
-
.option(
|
|
209
|
-
'--since <duration|iso>',
|
|
210
|
-
'only sends at/after this: 7d, 24h, 30m, or an ISO timestamp'
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
.option('--limit <n>', 'how many to list (default: 50)')
|
|
214
|
-
.option('--network <url>', 'network tRPC URL or staging (default: production)')
|
|
215
|
-
.option('--json', 'print a single JSON result on stdout')
|
|
216
|
-
.action(options =>
|
|
217
|
-
run('inbox list', options, async didkit => {
|
|
218
|
-
await runInboxList({ ...options, didkit });
|
|
219
|
-
})
|
|
220
|
-
);
|
|
221
|
-
};
|