@learncard/cli 3.4.17 → 3.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +10 -0
- package/dist/index.js +1933 -56
- package/package.json +24 -24
- package/rollup.config.js +25 -3
- package/scripts/embed-snippets.ts +25 -0
- package/src/consent-contract.ts +116 -0
- package/src/embed.ts +102 -0
- package/src/index.tsx +473 -147
- package/src/init.ts +26 -0
- package/src/open.test.ts +40 -0
- package/src/open.ts +186 -0
- package/src/out.test.ts +40 -0
- package/src/out.ts +16 -0
- package/src/phase-two.test.ts +154 -0
- package/src/project.test.ts +207 -0
- package/src/project.ts +394 -0
- package/src/revoke.test.ts +29 -0
- package/src/revoke.ts +65 -0
- package/src/send-template.test.ts +20 -0
- package/src/send.test.ts +45 -0
- package/src/send.ts +245 -0
- package/src/setup-signing.test.ts +62 -0
- package/src/setup-signing.ts +185 -0
- package/src/snippet-files.ts +16 -0
- package/src/status.test.ts +33 -0
- package/src/status.ts +96 -0
- package/src/token.test.ts +28 -0
- package/src/token.ts +138 -0
- package/src/verify.test.ts +35 -0
- package/src/verify.ts +66 -0
- package/src/webhook.ts +247 -0
- package/tsconfig.json +1 -0
package/src/init.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
connect,
|
|
3
|
+
ensureIdentity,
|
|
4
|
+
ensureProfile,
|
|
5
|
+
loadProject,
|
|
6
|
+
type ProjectOptions,
|
|
7
|
+
} from './project';
|
|
8
|
+
import { out } from './out';
|
|
9
|
+
|
|
10
|
+
/** Create the identity and profile every other command needs, without sending anything. */
|
|
11
|
+
export const runInit = async (options: ProjectOptions): Promise<void> => {
|
|
12
|
+
const project = await loadProject(process.cwd());
|
|
13
|
+
const fresh = !project.env.SECURE_SEED;
|
|
14
|
+
const identity = await ensureIdentity(project, options);
|
|
15
|
+
const learnCard = await connect(project, options);
|
|
16
|
+
await ensureProfile(learnCard, identity, project);
|
|
17
|
+
out.set({
|
|
18
|
+
profileId: identity.profileId,
|
|
19
|
+
displayName: identity.displayName,
|
|
20
|
+
did: learnCard.id.did(),
|
|
21
|
+
created: fresh,
|
|
22
|
+
envPath: project.envPath,
|
|
23
|
+
});
|
|
24
|
+
out.log(fresh ? 'Ready. Your identity is in .env (keep it out of git).' : 'Already set up.');
|
|
25
|
+
out.log('Next: npx @learncard/cli send you@example.com');
|
|
26
|
+
};
|
package/src/open.test.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { impliesNoBrowser, openPath, signInUrl } from './open';
|
|
3
|
+
import { appUrlFor, PRODUCTION_NETWORK, STAGING_NETWORK } from './project';
|
|
4
|
+
|
|
5
|
+
describe('open', () => {
|
|
6
|
+
it('maps networks to their app', () => {
|
|
7
|
+
expect(appUrlFor(PRODUCTION_NETWORK)).toBe('https://learncard.app');
|
|
8
|
+
expect(appUrlFor(STAGING_NETWORK)).toBe('https://staging.learncard.ai');
|
|
9
|
+
expect(appUrlFor('http://localhost:4000/trpc', 'http://localhost:3000/')).toBe(
|
|
10
|
+
'http://localhost:3000'
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('needs the matching .env value for resource targets', () => {
|
|
15
|
+
expect(openPath('portal', {})).toBe('/app-store/developer');
|
|
16
|
+
expect(openPath('template', {})).toBeNull();
|
|
17
|
+
expect(openPath('template', { TEMPLATE_URI: 'lc:network:x/trpc:boost:1' })).toBe(
|
|
18
|
+
'/app-store/developer?template=lc%3Anetwork%3Ax%2Ftrpc%3Aboost%3A1'
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('builds the sign-in URL with next as an app path, seed only in the fragment when asked', () => {
|
|
23
|
+
const plain = signInUrl('https://learncard.app', '/wallet');
|
|
24
|
+
expect(plain).toBe('https://learncard.app/developer/sign-in?next=%2Fwallet');
|
|
25
|
+
const withSeed = new URL(signInUrl('https://learncard.app', '/wallet', 'ab'.repeat(32)));
|
|
26
|
+
expect(withSeed.hash).toBe(`#seed=${'ab'.repeat(32)}`);
|
|
27
|
+
expect(withSeed.search).not.toContain('ab'.repeat(32));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('never launches a browser when headless, --json, or --no-browser', () => {
|
|
31
|
+
// A real TTY with no flags: fine to launch.
|
|
32
|
+
expect(impliesNoBrowser({}, true)).toBe(false);
|
|
33
|
+
// --json always implies headless, even at a real TTY.
|
|
34
|
+
expect(impliesNoBrowser({ json: true }, true)).toBe(true);
|
|
35
|
+
// No TTY (piped/CI) implies headless regardless of flags.
|
|
36
|
+
expect(impliesNoBrowser({}, false)).toBe(true);
|
|
37
|
+
// Explicit --no-browser (commander's `browser: false`) always wins.
|
|
38
|
+
expect(impliesNoBrowser({ browser: false }, true)).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/open.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import clipboard from 'clipboardy';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
appUrlFor,
|
|
6
|
+
loadProject,
|
|
7
|
+
PRODUCTION_NETWORK,
|
|
8
|
+
resolveServices,
|
|
9
|
+
STAGING_NETWORK,
|
|
10
|
+
type ProjectOptions,
|
|
11
|
+
} from './project';
|
|
12
|
+
import { out } from './out';
|
|
13
|
+
|
|
14
|
+
export type OpenTarget = 'portal' | 'wallet' | 'template' | 'contract' | 'integration';
|
|
15
|
+
|
|
16
|
+
export const OPEN_TARGETS: Record<OpenTarget, string> = {
|
|
17
|
+
portal: 'the Developer Portal',
|
|
18
|
+
wallet: 'your wallet',
|
|
19
|
+
template: 'the template you last sent from',
|
|
20
|
+
contract: 'your consent contract',
|
|
21
|
+
integration: "your Claim button's integration",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** App path for a target, given the project's .env. Returns null when the .env lacks what the target needs. */
|
|
25
|
+
export const openPath = (target: OpenTarget, env: Record<string, string>): string | null => {
|
|
26
|
+
switch (target) {
|
|
27
|
+
case 'portal':
|
|
28
|
+
return '/app-store/developer';
|
|
29
|
+
case 'wallet':
|
|
30
|
+
return '/wallet';
|
|
31
|
+
case 'template':
|
|
32
|
+
return env.TEMPLATE_URI
|
|
33
|
+
? `/app-store/developer?template=${encodeURIComponent(env.TEMPLATE_URI)}`
|
|
34
|
+
: null;
|
|
35
|
+
case 'contract':
|
|
36
|
+
return env.CONTRACT_URI
|
|
37
|
+
? `/app-store/developer?contract=${encodeURIComponent(env.CONTRACT_URI)}`
|
|
38
|
+
: null;
|
|
39
|
+
case 'integration':
|
|
40
|
+
return env.INTEGRATION_ID
|
|
41
|
+
? `/app-store/developer/integrations/${encodeURIComponent(env.INTEGRATION_ID)}`
|
|
42
|
+
: null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const signInUrl = (appUrl: string, next: string, seedInFragment?: string): string => {
|
|
47
|
+
const url = new URL('/developer/sign-in', appUrl);
|
|
48
|
+
url.searchParams.set('next', next);
|
|
49
|
+
if (seedInFragment) url.hash = `seed=${seedInFragment}`;
|
|
50
|
+
return url.toString();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const CLIPBOARD_TTL_MS = 60_000;
|
|
54
|
+
|
|
55
|
+
/** Only plain http(s) URLs may reach the OS browser launcher or seed a sign-in link. */
|
|
56
|
+
const isHttpUrl = (value: string): boolean => {
|
|
57
|
+
try {
|
|
58
|
+
return ['http:', 'https:'].includes(new URL(value).protocol);
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const launchBrowser = (url: string): void => {
|
|
65
|
+
const [cmd, args] =
|
|
66
|
+
process.platform === 'darwin'
|
|
67
|
+
? ['open', [url]]
|
|
68
|
+
: process.platform === 'win32'
|
|
69
|
+
? // No shell involved: rundll32 receives the URL as a normal argv entry, so
|
|
70
|
+
// cmd.exe metacharacters (&, |, ^, etc.) in the URL are never interpreted.
|
|
71
|
+
['rundll32', ['url.dll,FileProtocolHandler', url]]
|
|
72
|
+
: ['xdg-open', [url]];
|
|
73
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Clears the clipboard after the TTL, or immediately on Ctrl-C, but only if it still holds the
|
|
78
|
+
* seed we wrote (the user may have copied something else). Resolves once the clipboard is safe.
|
|
79
|
+
*/
|
|
80
|
+
const waitForClipboardClear = (seed: string): Promise<void> =>
|
|
81
|
+
new Promise(resolve => {
|
|
82
|
+
let settled = false;
|
|
83
|
+
const finish = async (): Promise<void> => {
|
|
84
|
+
if (settled) return;
|
|
85
|
+
settled = true;
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
process.removeListener('SIGINT', onSigint);
|
|
88
|
+
if ((await clipboard.read().catch(() => '')) === seed)
|
|
89
|
+
await clipboard.write('').catch(() => {});
|
|
90
|
+
out.log('Clipboard cleared.');
|
|
91
|
+
resolve();
|
|
92
|
+
};
|
|
93
|
+
const onSigint = (): void => {
|
|
94
|
+
void finish();
|
|
95
|
+
};
|
|
96
|
+
const timer = setTimeout(() => void finish(), CLIPBOARD_TTL_MS);
|
|
97
|
+
process.once('SIGINT', onSigint);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export interface OpenOptions extends ProjectOptions {
|
|
101
|
+
appUrl?: string;
|
|
102
|
+
urlFragment?: boolean;
|
|
103
|
+
/** commander's attribute name for `--no-browser`; false only when that flag is passed. */
|
|
104
|
+
browser?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type SeedDelivery = 'clipboard' | 'fragment' | 'none';
|
|
108
|
+
|
|
109
|
+
/** A script or `--json` run has nowhere to click "paste"; never launch a browser for it. */
|
|
110
|
+
export const impliesNoBrowser = (
|
|
111
|
+
options: { browser?: boolean; json?: boolean },
|
|
112
|
+
isTTY: boolean
|
|
113
|
+
): boolean => options.browser === false || !isTTY || !!options.json;
|
|
114
|
+
|
|
115
|
+
export const runOpen = async (
|
|
116
|
+
target: OpenTarget = 'portal',
|
|
117
|
+
options: OpenOptions
|
|
118
|
+
): Promise<void> => {
|
|
119
|
+
const project = await loadProject(process.cwd());
|
|
120
|
+
const seed = project.env.SECURE_SEED;
|
|
121
|
+
if (!seed)
|
|
122
|
+
throw new Error(
|
|
123
|
+
'No SECURE_SEED in .env. Run a command that creates one first, e.g. npx @learncard/cli send you@example.com'
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const path = openPath(target, project.env);
|
|
127
|
+
if (!path)
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Nothing saved for "${target}" yet. Run the command that creates it first (send --template, consent-contract, or embed).`
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const { network } = resolveServices(project.env, options.network);
|
|
133
|
+
if (options.appUrl && !isHttpUrl(options.appUrl))
|
|
134
|
+
throw new Error('--app-url must be a valid http(s) URL.');
|
|
135
|
+
if (!options.appUrl && network !== PRODUCTION_NETWORK && network !== STAGING_NETWORK)
|
|
136
|
+
throw new Error(
|
|
137
|
+
`${network} has no hosted LearnCard app. Pass --app-url <url> for the app connected to it.`
|
|
138
|
+
);
|
|
139
|
+
const appUrl = appUrlFor(network, options.appUrl);
|
|
140
|
+
const noBrowser = impliesNoBrowser(options, !!process.stdout.isTTY);
|
|
141
|
+
|
|
142
|
+
let url: string;
|
|
143
|
+
let seedDelivery: SeedDelivery;
|
|
144
|
+
let clipboardCleared: Promise<void> | undefined;
|
|
145
|
+
if (options.urlFragment) {
|
|
146
|
+
url = signInUrl(appUrl, path, seed);
|
|
147
|
+
out.log(
|
|
148
|
+
'Warning: --url-fragment puts your seed in the browser URL (history, screenshots).'
|
|
149
|
+
);
|
|
150
|
+
seedDelivery = 'fragment';
|
|
151
|
+
} else if (out.json) {
|
|
152
|
+
// Non-interactive: nobody is there to paste, and blocking a script for the clipboard TTL
|
|
153
|
+
// would be worse than not copying. Scripts that need the seed use --url-fragment.
|
|
154
|
+
url = signInUrl(appUrl, path);
|
|
155
|
+
out.log(
|
|
156
|
+
'Seed not copied in --json mode. Paste it from .env, or re-run with --url-fragment.'
|
|
157
|
+
);
|
|
158
|
+
seedDelivery = 'none';
|
|
159
|
+
} else {
|
|
160
|
+
url = signInUrl(appUrl, path);
|
|
161
|
+
try {
|
|
162
|
+
await clipboard.write(seed);
|
|
163
|
+
out.log(`Copied your seed to the clipboard (clears in ${CLIPBOARD_TTL_MS / 1000}s).`);
|
|
164
|
+
seedDelivery = 'clipboard';
|
|
165
|
+
clipboardCleared = waitForClipboardClear(seed);
|
|
166
|
+
} catch {
|
|
167
|
+
out.log(
|
|
168
|
+
'Clipboard unavailable. Paste the seed from .env, or re-run with --url-fragment.'
|
|
169
|
+
);
|
|
170
|
+
seedDelivery = 'none';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!options.urlFragment) out.log('→ Click "Paste from clipboard", then Sign in.');
|
|
175
|
+
if (!noBrowser) launchBrowser(url);
|
|
176
|
+
// Printed last so `$(cli open | tail -1)` captures this line in non-json mode.
|
|
177
|
+
out.log(
|
|
178
|
+
`Opening ${OPEN_TARGETS[target]}: ${options.urlFragment ? url.split('#')[0] + '#seed=…' : url}`
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
out.set({ url, target, next: path, seedDelivery });
|
|
182
|
+
|
|
183
|
+
// Keep the process alive until the clipboard is actually cleared (or Ctrl-C clears it early);
|
|
184
|
+
// index.tsx's runCommand exits right after this promise resolves.
|
|
185
|
+
await clipboardCleared;
|
|
186
|
+
};
|
package/src/out.test.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { out } from './out';
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
out.json = false;
|
|
6
|
+
out.result = {};
|
|
7
|
+
vi.restoreAllMocks();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('out', () => {
|
|
11
|
+
it('routes log to console.log in human mode', () => {
|
|
12
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
13
|
+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
14
|
+
|
|
15
|
+
out.json = false;
|
|
16
|
+
out.log('hello', 1);
|
|
17
|
+
|
|
18
|
+
expect(log).toHaveBeenCalledWith('hello', 1);
|
|
19
|
+
expect(error).not.toHaveBeenCalled();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('routes log to console.error in json mode, keeping stdout free for the result', () => {
|
|
23
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
24
|
+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
25
|
+
|
|
26
|
+
out.json = true;
|
|
27
|
+
out.log('hidden from stdout');
|
|
28
|
+
|
|
29
|
+
expect(error).toHaveBeenCalledWith('hidden from stdout');
|
|
30
|
+
expect(log).not.toHaveBeenCalled();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('accumulates result patches, later keys winning on conflict', () => {
|
|
34
|
+
out.result = {};
|
|
35
|
+
out.set({ a: 1, b: 2 });
|
|
36
|
+
out.set({ b: 3 });
|
|
37
|
+
|
|
38
|
+
expect(out.result).toEqual({ a: 1, b: 3 });
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/out.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared output router for every CLI command.
|
|
3
|
+
*
|
|
4
|
+
* Human mode: `out.log(...)` behaves exactly like `console.log(...)`.
|
|
5
|
+
* `--json` mode: `out.log(...)` is redirected to stderr (so stdout stays reserved
|
|
6
|
+
* for the single JSON result line) and each command accumulates its result via
|
|
7
|
+
* `out.set(...)`. `index.tsx`'s `runCommand` prints the final `out.result` object
|
|
8
|
+
* as one JSON line on stdout after the command succeeds (or an error envelope on
|
|
9
|
+
* failure) and sets `out.json` before running the command's action.
|
|
10
|
+
*/
|
|
11
|
+
export const out = {
|
|
12
|
+
json: false,
|
|
13
|
+
log: (...a: unknown[]) => (out.json ? console.error(...a) : console.log(...a)),
|
|
14
|
+
result: {} as Record<string, unknown>,
|
|
15
|
+
set: (patch: Record<string, unknown>) => Object.assign(out.result, patch),
|
|
16
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { consentUrl } from './consent-contract';
|
|
3
|
+
import { personalizeClaimButton, validateDomains } from './embed';
|
|
4
|
+
import { PRODUCTION_NETWORK, STAGING_NETWORK } from './project';
|
|
5
|
+
import { closeWebhookReceiver, loadWebhookModule, webhookConfig } from './webhook';
|
|
6
|
+
|
|
7
|
+
describe('consent URL', () => {
|
|
8
|
+
it('encodes both parameters and selects the app for the network', () => {
|
|
9
|
+
for (const [network, origin] of [
|
|
10
|
+
[PRODUCTION_NETWORK, 'https://learncard.app'],
|
|
11
|
+
[STAGING_NETWORK, 'https://staging.learncard.ai'],
|
|
12
|
+
['http://localhost:4000/trpc', 'https://learncard.app'],
|
|
13
|
+
]) {
|
|
14
|
+
const url = new URL(
|
|
15
|
+
consentUrl('lc:network:contract?a&b', 'https://example.com/cb?a=1&b=2', network!)
|
|
16
|
+
);
|
|
17
|
+
expect(url.origin).toBe(origin);
|
|
18
|
+
expect(url.searchParams.get('uri')).toBe('lc:network:contract?a&b');
|
|
19
|
+
expect(url.searchParams.get('returnTo')).toBe('https://example.com/cb?a=1&b=2');
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('embed helpers', () => {
|
|
25
|
+
it('uses the network domain/origin validator, including its error message', () => {
|
|
26
|
+
expect(validateDomains('http://localhost:3000, example.com')).toEqual([
|
|
27
|
+
'http://localhost:3000',
|
|
28
|
+
'example.com',
|
|
29
|
+
]);
|
|
30
|
+
for (const invalid of [
|
|
31
|
+
'',
|
|
32
|
+
'https://example.com/path',
|
|
33
|
+
'file://example.com',
|
|
34
|
+
'https://*.example.com',
|
|
35
|
+
]) {
|
|
36
|
+
expect(() => validateDomains(invalid)).toThrow(/Must be a valid/);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
it('substitutes keys safely, including replacement and script-closing characters', () => {
|
|
40
|
+
const html = personalizeClaimButton("pk_$&'</script>", 'http://localhost:4000/api');
|
|
41
|
+
expect(html).not.toContain('PUBLISHABLE_KEY_PLACEHOLDER');
|
|
42
|
+
expect(html).toContain("pk_$&'\\u003c/script>");
|
|
43
|
+
expect(html).toContain('http://localhost:4000/api');
|
|
44
|
+
expect(html).toContain('LearnCard.init(');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('canonical webhook helpers', () => {
|
|
49
|
+
it('resolves trusted DID and port from the project with explicit overrides', () => {
|
|
50
|
+
const env = { PORT: '9000', EXPECTED_NETWORK_DID: 'did:key:project' };
|
|
51
|
+
expect(webhookConfig(env, {}, {})).toEqual({ port: 9000, expectedDid: 'did:key:project' });
|
|
52
|
+
expect(
|
|
53
|
+
webhookConfig(
|
|
54
|
+
env,
|
|
55
|
+
{ port: '9001' },
|
|
56
|
+
{ PORT: '9002', EXPECTED_NETWORK_DID: 'did:key:runtime' }
|
|
57
|
+
)
|
|
58
|
+
).toEqual({ port: 9001, expectedDid: 'did:key:runtime' });
|
|
59
|
+
expect(() => webhookConfig({}, { port: '0' }, {})).toThrow('Port must be');
|
|
60
|
+
});
|
|
61
|
+
it('closes connections even when verification never completes', async () => {
|
|
62
|
+
const { createWebhookReceiver } = await loadWebhookModule();
|
|
63
|
+
let started!: () => void;
|
|
64
|
+
const verifying = new Promise<void>(resolve => {
|
|
65
|
+
started = resolve;
|
|
66
|
+
});
|
|
67
|
+
const server = createWebhookReceiver({
|
|
68
|
+
invoke: {
|
|
69
|
+
verifyPresentation: () => {
|
|
70
|
+
started();
|
|
71
|
+
return new Promise(() => {});
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
76
|
+
const address = server.address();
|
|
77
|
+
if (!address || typeof address === 'string') throw new Error('Expected TCP address');
|
|
78
|
+
const request = fetch(`http://127.0.0.1:${address.port}`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { Authorization: 'Bearer pending' },
|
|
81
|
+
body: '{}',
|
|
82
|
+
}).catch(() => undefined);
|
|
83
|
+
await verifying;
|
|
84
|
+
await closeWebhookReceiver(server);
|
|
85
|
+
await request;
|
|
86
|
+
expect(server.listening).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
it('rejects invalid authentication and payloads, and acknowledges duplicate events once', async () => {
|
|
89
|
+
const { createWebhookReceiver } = await loadWebhookModule();
|
|
90
|
+
const verifyPresentation = vi.fn().mockResolvedValue({ errors: [] });
|
|
91
|
+
const server = createWebhookReceiver({ invoke: { verifyPresentation } }, 'did:key:trusted');
|
|
92
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
93
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
94
|
+
const address = server.address();
|
|
95
|
+
if (!address || typeof address === 'string') throw new Error('Expected TCP address');
|
|
96
|
+
const url = `http://127.0.0.1:${address.port}`;
|
|
97
|
+
const token = (iss: string): string =>
|
|
98
|
+
`header.${Buffer.from(JSON.stringify({ iss })).toString('base64url')}.signature`;
|
|
99
|
+
const post = (body: string, bearer = token('did:key:trusted')) =>
|
|
100
|
+
fetch(url, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { Authorization: `Bearer ${bearer}` },
|
|
103
|
+
body,
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
expect((await fetch(url, { method: 'POST' })).status).toBe(401);
|
|
107
|
+
expect((await post('{}', token('did:key:other'))).status).toBe(403);
|
|
108
|
+
verifyPresentation.mockResolvedValueOnce({ errors: ['invalid signature'] });
|
|
109
|
+
expect((await post('{}')).status).toBe(401);
|
|
110
|
+
expect((await post('{')).status).toBe(400);
|
|
111
|
+
expect((await post('{}')).status).toBe(400);
|
|
112
|
+
const data = { inbox: { issuanceId: 'same-id', status: 'PENDING' } };
|
|
113
|
+
const delivered = JSON.stringify({ type: 'ISSUANCE_DELIVERED', data });
|
|
114
|
+
expect((await post(delivered)).status).toBe(200);
|
|
115
|
+
expect((await post(delivered)).status).toBe(200);
|
|
116
|
+
expect((await post(JSON.stringify({ type: 'ISSUANCE_CLAIMED', data }))).status).toBe(
|
|
117
|
+
200
|
|
118
|
+
);
|
|
119
|
+
expect(log).toHaveBeenCalledTimes(2);
|
|
120
|
+
expect(verifyPresentation).toHaveBeenCalledWith(expect.any(String), {
|
|
121
|
+
proofFormat: 'jwt',
|
|
122
|
+
});
|
|
123
|
+
} finally {
|
|
124
|
+
log.mockRestore();
|
|
125
|
+
await new Promise<void>((resolve, reject) =>
|
|
126
|
+
server.close(error => (error ? reject(error) : resolve()))
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
it('extracts only bearer credentials', async () => {
|
|
131
|
+
const { extractBearer } = await loadWebhookModule();
|
|
132
|
+
expect(extractBearer('Bearer abc.def.ghi')).toBe('abc.def.ghi');
|
|
133
|
+
expect(extractBearer('bearer token')).toBe('token');
|
|
134
|
+
for (const invalid of [undefined, null, [], 'Basic abc', 'Bearer', 'Bearer a b']) {
|
|
135
|
+
expect(extractBearer(invalid)).toBeUndefined();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
it('deduplicates retries without conflating delivered and claimed', async () => {
|
|
139
|
+
const { webhookDedupeKey } = await loadWebhookModule();
|
|
140
|
+
const data = { inbox: { issuanceId: 'abc' } };
|
|
141
|
+
expect(webhookDedupeKey({ type: 'ISSUANCE_DELIVERED', data })).toBe(
|
|
142
|
+
'ISSUANCE_DELIVERED:abc'
|
|
143
|
+
);
|
|
144
|
+
expect(webhookDedupeKey({ type: 'ISSUANCE_CLAIMED', data })).toBe('ISSUANCE_CLAIMED:abc');
|
|
145
|
+
for (const invalid of [
|
|
146
|
+
null,
|
|
147
|
+
{},
|
|
148
|
+
{ type: 'OTHER', data },
|
|
149
|
+
{ type: 'ISSUANCE_DELIVERED', data: { inbox: {} } },
|
|
150
|
+
]) {
|
|
151
|
+
expect(webhookDedupeKey(invalid)).toBeUndefined();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
assertProjectNetwork,
|
|
7
|
+
KEYS,
|
|
8
|
+
createPrompts,
|
|
9
|
+
ensureIdentity,
|
|
10
|
+
loadProject,
|
|
11
|
+
parseEnv,
|
|
12
|
+
resolveServices,
|
|
13
|
+
saveProject,
|
|
14
|
+
upsertEnv,
|
|
15
|
+
} from './project';
|
|
16
|
+
|
|
17
|
+
afterEach(() => vi.restoreAllMocks());
|
|
18
|
+
|
|
19
|
+
describe('createPrompts non-interactive behavior', () => {
|
|
20
|
+
const originalIsTTY = process.stdin.isTTY;
|
|
21
|
+
const originalLcYes = process.env.LC_YES;
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
|
|
25
|
+
if (originalLcYes === undefined) delete process.env.LC_YES;
|
|
26
|
+
else process.env.LC_YES = originalLcYes;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns the fallback without opening a prompt when yes is true', async () => {
|
|
30
|
+
const prompts = createPrompts(true);
|
|
31
|
+
await expect(prompts.ask('Display name', 'My Organization')).resolves.toBe(
|
|
32
|
+
'My Organization'
|
|
33
|
+
);
|
|
34
|
+
prompts.close();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns the fallback when stdin is not a TTY, even without --yes', async () => {
|
|
38
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
|
|
39
|
+
const prompts = createPrompts(undefined);
|
|
40
|
+
await expect(prompts.ask('Display name', 'My Organization')).resolves.toBe(
|
|
41
|
+
'My Organization'
|
|
42
|
+
);
|
|
43
|
+
prompts.close();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('treats LC_YES=1 as non-interactive even when stdin looks like a real TTY', async () => {
|
|
47
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
48
|
+
process.env.LC_YES = '1';
|
|
49
|
+
const prompts = createPrompts(undefined);
|
|
50
|
+
await expect(prompts.ask('Display name', 'My Organization')).resolves.toBe(
|
|
51
|
+
'My Organization'
|
|
52
|
+
);
|
|
53
|
+
prompts.close();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('throws a descriptive error when non-interactive and no fallback exists', async () => {
|
|
57
|
+
const prompts = createPrompts(true);
|
|
58
|
+
await expect(prompts.ask('Recipient email (--to <email>)', '')).rejects.toThrow(
|
|
59
|
+
'Recipient email (--to <email>) is required when running non-interactively. Pass it as an argument or flag.'
|
|
60
|
+
);
|
|
61
|
+
prompts.close();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe('project context', () => {
|
|
65
|
+
it('parses exported and commented seeds without changing identity', () => {
|
|
66
|
+
expect(
|
|
67
|
+
parseEnv('export SECURE_SEED="original" # keep\nPROFILE_ID=issuer # comment\n')
|
|
68
|
+
).toEqual({ SECURE_SEED: 'original', PROFILE_ID: 'issuer' });
|
|
69
|
+
expect(upsertEnv('export PROFILE_ID=old\n', { PROFILE_ID: 'new' })).toBe(
|
|
70
|
+
'PROFILE_ID=new\n'
|
|
71
|
+
);
|
|
72
|
+
expect(() => parseEnv('SECURE_SEED=one\nSECURE_SEED=two\n')).toThrow('Conflicting');
|
|
73
|
+
});
|
|
74
|
+
it('rejects network changes when resources are already saved', () => {
|
|
75
|
+
const project = {
|
|
76
|
+
existing: '',
|
|
77
|
+
envPath: '/unused/.env',
|
|
78
|
+
env: { TEMPLATE_URI: 'lc:template' },
|
|
79
|
+
};
|
|
80
|
+
expect(() =>
|
|
81
|
+
assertProjectNetwork(project, 'https://staging.network.learncard.com/trpc')
|
|
82
|
+
).toThrow('separate folder');
|
|
83
|
+
expect(() =>
|
|
84
|
+
assertProjectNetwork(project, 'https://network.learncard.com/trpc')
|
|
85
|
+
).not.toThrow();
|
|
86
|
+
});
|
|
87
|
+
it('documents the stable shared keys', () => {
|
|
88
|
+
expect(Object.keys(KEYS)).toEqual([
|
|
89
|
+
'SECURE_SEED',
|
|
90
|
+
'PROFILE_ID',
|
|
91
|
+
'DISPLAY_NAME',
|
|
92
|
+
'NETWORK_URL',
|
|
93
|
+
'SIGNING_AUTHORITY_NAME',
|
|
94
|
+
'SIGNING_AUTHORITY_ENDPOINT',
|
|
95
|
+
'API_TOKEN',
|
|
96
|
+
'API_TOKEN_SCOPE',
|
|
97
|
+
'TEMPLATE_URI',
|
|
98
|
+
'CONTRACT_URI',
|
|
99
|
+
'PUBLISHABLE_KEY',
|
|
100
|
+
'INTEGRATION_ID',
|
|
101
|
+
]);
|
|
102
|
+
expect(Object.values(KEYS)).toEqual(Object.keys(KEYS));
|
|
103
|
+
});
|
|
104
|
+
it('preserves unrelated lines and quotes scope lists', () => {
|
|
105
|
+
const result = upsertEnv('# keep\nOTHER=one\n', {
|
|
106
|
+
API_TOKEN_SCOPE: 'boosts:write inbox:read',
|
|
107
|
+
});
|
|
108
|
+
expect(result).toBe('# keep\nOTHER=one\nAPI_TOKEN_SCOPE="boosts:write inbox:read"\n');
|
|
109
|
+
expect(parseEnv(result).API_TOKEN_SCOPE).toBe('boosts:write inbox:read');
|
|
110
|
+
});
|
|
111
|
+
it('does not ask for a display name when a profile already exists', async () => {
|
|
112
|
+
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'lc-project-'));
|
|
113
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
114
|
+
try {
|
|
115
|
+
await fs.writeFile(path.join(cwd, '.env'), 'SECURE_SEED=existing\nPROFILE_ID=issuer\n');
|
|
116
|
+
const project = await loadProject(cwd);
|
|
117
|
+
vi.spyOn(process.stdin, 'isTTY', 'get').mockReturnValue(true);
|
|
118
|
+
const identity = await Promise.race([
|
|
119
|
+
ensureIdentity(project, {}),
|
|
120
|
+
new Promise<never>((_, reject) =>
|
|
121
|
+
setTimeout(() => reject(new Error('prompted for a display name')), 500)
|
|
122
|
+
),
|
|
123
|
+
]);
|
|
124
|
+
expect(identity.profileId).toBe('issuer');
|
|
125
|
+
expect((await loadProject(cwd)).env.DISPLAY_NAME).toBeUndefined();
|
|
126
|
+
} finally {
|
|
127
|
+
vi.restoreAllMocks();
|
|
128
|
+
await fs.rm(cwd, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
it('accumulates keys, does not rewrite unchanged values, and never replaces a seed', async () => {
|
|
132
|
+
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'lc-project-'));
|
|
133
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
134
|
+
try {
|
|
135
|
+
await fs.writeFile(
|
|
136
|
+
path.join(cwd, '.env'),
|
|
137
|
+
'# keep\nSECURE_SEED=existing\nPROFILE_ID=issuer\n'
|
|
138
|
+
);
|
|
139
|
+
await fs.writeFile(path.join(cwd, '.gitignore'), '.env\n');
|
|
140
|
+
const project = await loadProject(cwd);
|
|
141
|
+
const identity = await ensureIdentity(project, { yes: true, profileId: 'ignored' });
|
|
142
|
+
expect(identity.seed).toBe('existing');
|
|
143
|
+
expect(identity.profileId).toBe('issuer');
|
|
144
|
+
expect(console.log).not.toHaveBeenCalled();
|
|
145
|
+
await saveProject(project, { TEMPLATE_URI: 'lc:template' });
|
|
146
|
+
await saveProject(project, { API_TOKEN: 'secret' });
|
|
147
|
+
expect((await fs.stat(project.envPath)).mode & 0o777).toBe(0o600);
|
|
148
|
+
expect((await loadProject(cwd)).env).toMatchObject({
|
|
149
|
+
SECURE_SEED: 'existing',
|
|
150
|
+
TEMPLATE_URI: 'lc:template',
|
|
151
|
+
API_TOKEN: 'secret',
|
|
152
|
+
});
|
|
153
|
+
expect(console.log).toHaveBeenCalledWith('Wrote TEMPLATE_URI to .env');
|
|
154
|
+
await expect(saveProject(project, { SECURE_SEED: 'replacement' })).rejects.toThrow(
|
|
155
|
+
'Cannot replace'
|
|
156
|
+
);
|
|
157
|
+
} finally {
|
|
158
|
+
await fs.rm(cwd, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
it('refuses stale identity writes and symlinked env files', async () => {
|
|
162
|
+
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'lc-project-safety-'));
|
|
163
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
164
|
+
try {
|
|
165
|
+
const stale = await loadProject(cwd);
|
|
166
|
+
const current = await loadProject(cwd);
|
|
167
|
+
await saveProject(current, { SECURE_SEED: 'original' });
|
|
168
|
+
await expect(saveProject(stale, { SECURE_SEED: 'different' })).rejects.toThrow(
|
|
169
|
+
'Cannot replace'
|
|
170
|
+
);
|
|
171
|
+
expect((await loadProject(cwd)).env.SECURE_SEED).toBe('original');
|
|
172
|
+
await fs.rename(current.envPath, path.join(cwd, 'target'));
|
|
173
|
+
await fs.symlink(path.join(cwd, 'target'), current.envPath);
|
|
174
|
+
await expect(loadProject(cwd)).rejects.toThrow('symlink');
|
|
175
|
+
} finally {
|
|
176
|
+
log.mockRestore();
|
|
177
|
+
await fs.rm(cwd, { recursive: true, force: true });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
it('resolves staging services together and preserves explicit local LCA URLs', () => {
|
|
181
|
+
expect(resolveServices({}, 'staging', {})).toEqual({
|
|
182
|
+
network: 'https://staging.network.learncard.com/trpc',
|
|
183
|
+
cloud: 'https://staging.cloud.learncard.com/trpc',
|
|
184
|
+
lcaAPI: 'https://staging.api.learncard.app/trpc',
|
|
185
|
+
});
|
|
186
|
+
expect(
|
|
187
|
+
resolveServices({ NETWORK_URL: 'http://localhost:4000/trpc' }, undefined, {
|
|
188
|
+
LCA_API_URL: 'http://localhost:5200/api',
|
|
189
|
+
}).lcaAPI
|
|
190
|
+
).toBe('http://localhost:5200/api');
|
|
191
|
+
expect(() => resolveServices({}, 'file:///tmp/network', {})).toThrow('HTTP');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('upsertEnv value quoting', () => {
|
|
196
|
+
it('writes percent-encoded network URIs bare so shell scripts can read them', () => {
|
|
197
|
+
expect(upsertEnv('', { TEMPLATE_URI: 'lc:network:localhost%3A4000/trpc:boost:abc' })).toBe(
|
|
198
|
+
'TEMPLATE_URI=lc:network:localhost%3A4000/trpc:boost:abc\n'
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('quotes values with spaces or shell-significant characters', () => {
|
|
203
|
+
expect(upsertEnv('', { API_TOKEN_SCOPE: 'boosts:write inbox:read' })).toBe(
|
|
204
|
+
'API_TOKEN_SCOPE="boosts:write inbox:read"\n'
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
});
|