@profullstack/coinpay 0.28.0 → 0.30.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 +11 -0
- package/bin/coinpay.js +144 -74
- package/package.json +5 -1
- package/src/agents.d.ts +19 -0
- package/src/agents.js +11 -0
- package/src/client.d.ts +11 -0
- package/src/client.js +3 -0
- package/src/index.d.ts +2 -0
- package/src/index.js +2 -0
- package/src/oauth-login.js +93 -0
- package/src/secrets-vault.js +234 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ All notable changes to `@profullstack/coinpay` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.29.0] - 2026-10-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `coinpay login` uses OAuth 2.1 either way: authorization code + PKCE on a loopback redirect when a
|
|
12
|
+
browser is on this machine, the device flow (RFC 8628) otherwise. Over SSH or with no display it
|
|
13
|
+
picks the device flow by itself; `--device` / `--browser` force one. The device flow now gets real
|
|
14
|
+
OAuth tokens (rotating refresh token) from `/api/oauth/device_authorization` and the token
|
|
15
|
+
endpoint instead of the old `/api/cli-auth` session.
|
|
16
|
+
- `coinpay logout` revokes the session at CoinPay (RFC 7009, `/api/oauth/revoke`) before forgetting it.
|
|
17
|
+
- `deviceLogin`, `revokeToken` and `hasLocalBrowser` exported from `src/oauth-login.js`.
|
|
18
|
+
|
|
8
19
|
## [0.28.0] - 2026-10-29
|
|
9
20
|
|
|
10
21
|
### Added
|
package/bin/coinpay.js
CHANGED
|
@@ -85,6 +85,7 @@ import {
|
|
|
85
85
|
moveDocument,
|
|
86
86
|
} from '../src/documents.js';
|
|
87
87
|
import { publishDirectory } from '../src/documents-publish.js';
|
|
88
|
+
import { getVault, initVault, unlockVault, lockVault, payoutWallet } from '../src/secrets-vault.js';
|
|
88
89
|
import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'fs';
|
|
89
90
|
import { execSync, spawn } from 'child_process';
|
|
90
91
|
import { createInterface } from 'readline';
|
|
@@ -227,6 +228,9 @@ function createUnauthenticatedClient() {
|
|
|
227
228
|
}
|
|
228
229
|
|
|
229
230
|
const BOOLEAN_FLAGS = new Set([
|
|
231
|
+
// vault, payout wallet
|
|
232
|
+
'no-recovery',
|
|
233
|
+
'create',
|
|
230
234
|
// projects, time, client portal
|
|
231
235
|
'non-billable',
|
|
232
236
|
'unbilled',
|
|
@@ -500,8 +504,8 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
500
504
|
set-url <base-url> Set custom API URL
|
|
501
505
|
show Show current configuration
|
|
502
506
|
|
|
503
|
-
${colors.bright}login${colors.reset} Sign in (OAuth 2.1
|
|
504
|
-
${colors.bright}logout${colors.reset}
|
|
507
|
+
${colors.bright}login${colors.reset} Sign in (OAuth 2.1: browser + PKCE, or the device flow over SSH / --device)
|
|
508
|
+
${colors.bright}logout${colors.reset} Sign out and revoke the session at CoinPay
|
|
505
509
|
|
|
506
510
|
${colors.bright}auth${colors.reset}
|
|
507
511
|
register Register new merchant account
|
|
@@ -618,6 +622,18 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
618
622
|
get <id> Get business details
|
|
619
623
|
list List businesses
|
|
620
624
|
update <id> Update business
|
|
625
|
+
payout-wallet <id> Show the business payout (hot) wallet; --create makes it
|
|
626
|
+
(needs the secrets vault set up + unlocked, and an API key)
|
|
627
|
+
|
|
628
|
+
${colors.bright}vault${colors.reset} Your secrets vault (sealed to a passphrase only you know;
|
|
629
|
+
payouts, bank links and payout wallets need it). Needs an API key
|
|
630
|
+
(COINPAY_API_KEY / config set-key): apps signed in with CoinPay cannot.
|
|
631
|
+
Passphrase: COINPAY_VAULT_PASSPHRASE or a hidden prompt.
|
|
632
|
+
status Exists? Unlocked until when?
|
|
633
|
+
init Create it and unlock it; prints a recovery key ONCE
|
|
634
|
+
[--no-recovery] [--ttl 8]
|
|
635
|
+
unlock [--ttl 8 (hours, max 168)] [--recovery-key KEY]
|
|
636
|
+
lock Forget the unlock on the server now
|
|
621
637
|
|
|
622
638
|
${colors.bright}rates${colors.reset}
|
|
623
639
|
get <crypto> Get exchange rate
|
|
@@ -2451,6 +2467,63 @@ async function handleTokens(subcommand, args, flags) {
|
|
|
2451
2467
|
/**
|
|
2452
2468
|
* Business commands
|
|
2453
2469
|
*/
|
|
2470
|
+
async function readVaultPassphrase(confirm = false) {
|
|
2471
|
+
if (process.env.COINPAY_VAULT_PASSPHRASE) return process.env.COINPAY_VAULT_PASSPHRASE;
|
|
2472
|
+
const pass = await promptPassword('Vault passphrase: ');
|
|
2473
|
+
if (confirm) {
|
|
2474
|
+
const again = await promptPassword('Repeat passphrase: ');
|
|
2475
|
+
if (again !== pass) {
|
|
2476
|
+
print.error('Passphrases do not match');
|
|
2477
|
+
process.exit(1);
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
return pass;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function vaultTtl(flags) {
|
|
2484
|
+
if (flags.ttl === undefined) return 8;
|
|
2485
|
+
const ttl = Number(flags.ttl);
|
|
2486
|
+
if (!Number.isFinite(ttl) || ttl <= 0 || ttl > 168) usageExit('--ttl is hours, 1 to 168');
|
|
2487
|
+
return ttl;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
async function handleVault(subcommand, args, flags) {
|
|
2491
|
+
const client = createClient();
|
|
2492
|
+
switch (subcommand || 'status') {
|
|
2493
|
+
case 'status': {
|
|
2494
|
+
const v = await getVault(client);
|
|
2495
|
+
if (flags.json) return print.json({ exists: v.exists, unlocked: !!v.unlocked, expiresAt: v.expiresAt ?? null });
|
|
2496
|
+
if (!v.exists) return print.info('No secrets vault yet: coinpay vault init');
|
|
2497
|
+
return v.unlocked
|
|
2498
|
+
? print.success('Vault unlocked until ' + v.expiresAt)
|
|
2499
|
+
: print.info('Vault exists and is locked: coinpay vault unlock');
|
|
2500
|
+
}
|
|
2501
|
+
case 'init': {
|
|
2502
|
+
const passphrase = await readVaultPassphrase(!process.env.COINPAY_VAULT_PASSPHRASE);
|
|
2503
|
+
const out = await initVault(client, { passphrase, withRecoveryKey: !flags['no-recovery'], ttlHours: vaultTtl(flags) });
|
|
2504
|
+
if (flags.json) return print.json(out);
|
|
2505
|
+
print.success('Secrets vault created and unlocked until ' + out.expiresAt);
|
|
2506
|
+
if (out.recoveryKey) {
|
|
2507
|
+
print.warn('Recovery key (shown once; store it somewhere safe, it opens the vault without the passphrase):');
|
|
2508
|
+
console.log(out.recoveryKey);
|
|
2509
|
+
}
|
|
2510
|
+
return;
|
|
2511
|
+
}
|
|
2512
|
+
case 'unlock': {
|
|
2513
|
+
const recoveryKey = flags['recovery-key'];
|
|
2514
|
+
const passphrase = recoveryKey ? undefined : await readVaultPassphrase();
|
|
2515
|
+
const out = await unlockVault(client, { passphrase, recoveryKey, ttlHours: vaultTtl(flags) });
|
|
2516
|
+
return flags.json ? print.json(out) : print.success('Vault unlocked until ' + out.expiresAt);
|
|
2517
|
+
}
|
|
2518
|
+
case 'lock': {
|
|
2519
|
+
await lockVault(client);
|
|
2520
|
+
return print.success('Vault locked');
|
|
2521
|
+
}
|
|
2522
|
+
default:
|
|
2523
|
+
usageExit('coinpay vault <status|init|unlock|lock>');
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2454
2527
|
async function handleBusiness(subcommand, args, flags) {
|
|
2455
2528
|
const client = createClient();
|
|
2456
2529
|
|
|
@@ -2486,6 +2559,13 @@ async function handleBusiness(subcommand, args, flags) {
|
|
|
2486
2559
|
break;
|
|
2487
2560
|
}
|
|
2488
2561
|
|
|
2562
|
+
case 'payout-wallet': {
|
|
2563
|
+
if (!args[0]) usageExit('coinpay business payout-wallet <business-id> [--create]');
|
|
2564
|
+
const out = await payoutWallet(client, args[0], { create: !!flags.create });
|
|
2565
|
+
print.json(out.payout_wallet ?? out);
|
|
2566
|
+
break;
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2489
2569
|
case 'categories': {
|
|
2490
2570
|
// Fetched rather than hardcoded: the taxonomy is server-side and a stale
|
|
2491
2571
|
// copy in the CLI is how "Select a valid business category" happens twice.
|
|
@@ -4509,7 +4589,8 @@ const SUBCOMMANDS = {
|
|
|
4509
4589
|
payment: [['create', 'Create a new payment'], ['get', 'Get payment details <id>'], ['list', 'List payments'], ['qr', 'Get payment QR code <id>']],
|
|
4510
4590
|
invoice: [['create', 'Create a draft invoice'], ['list', 'List invoices'], ['get', 'Get invoice details <id>'], ['update', 'Update a draft invoice <id>'], ['publish', 'Publish an invoice <id>'], ['send', 'Send an invoice <id>'], ['delete', 'Delete a draft invoice <id>']],
|
|
4511
4591
|
tokens: [['list', 'List checkout tokens']],
|
|
4512
|
-
business: [['create', 'Create a new business'], ['get', 'Get business details <id>'], ['list', 'List businesses'], ['update', 'Update business <id>']],
|
|
4592
|
+
business: [['create', 'Create a new business'], ['get', 'Get business details <id>'], ['list', 'List businesses'], ['update', 'Update business <id>'], ['payout-wallet', 'Payout wallet <id> [--create]']],
|
|
4593
|
+
vault: [['status', 'Secrets vault status'], ['init', 'Create + unlock the vault'], ['unlock', 'Unlock [--ttl hours]'], ['lock', 'Lock now']],
|
|
4513
4594
|
rates: [['get', 'Get exchange rate <crypto>'], ['list', 'Get all exchange rates']],
|
|
4514
4595
|
wallet: [['create', 'Create a new wallet'], ['import', 'Import wallet <mnemonic>'], ['unlock', 'Decrypt wallet and show info'], ['info', 'Show wallet info'], ['addresses', 'List all addresses'], ['derive', 'Derive new address <chain>'], ['derive-missing', 'Derive missing chains'], ['balance', 'Get balance(s) [chain]'], ['send', 'Send a transaction'], ['history', 'Transaction history'], ['backup', 'Export encrypted backup'], ['delete', 'Delete local wallet file']],
|
|
4515
4596
|
swap: [['coins', 'List supported coins'], ['quote', 'Get swap quote'], ['create', 'Create swap transaction'], ['status', 'Check swap status <swap-id>'], ['history', 'Swap history']],
|
|
@@ -4746,22 +4827,28 @@ async function runInteractiveMenu() {
|
|
|
4746
4827
|
}
|
|
4747
4828
|
}
|
|
4748
4829
|
|
|
4749
|
-
//
|
|
4750
|
-
//
|
|
4751
|
-
//
|
|
4830
|
+
// `coinpay login`: OAuth 2.1 as the public client `coinpay-cli`. With a local
|
|
4831
|
+
// browser it uses authorization code + PKCE on a loopback redirect; over SSH
|
|
4832
|
+
// or with no display (or --device) it uses the device flow (RFC 8628): it
|
|
4833
|
+
// prints a code to type at coinpayportal.com/oauth/device on any device.
|
|
4834
|
+
// Either way it stores a rotating refresh token in ~/.coinpay.json.
|
|
4752
4835
|
async function handleLogin(flags = {}) {
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4836
|
+
const cfg = loadConfig();
|
|
4837
|
+
const { loopbackLogin, deviceLogin, hasLocalBrowser, toStoredSession } = await import('../src/oauth-login.js');
|
|
4838
|
+
const useDevice = Boolean(flags.device) || (!flags.browser && !hasLocalBrowser());
|
|
4839
|
+
|
|
4840
|
+
const save = async (tokens, how) => {
|
|
4841
|
+
cfg.oauth = toStoredSession(tokens);
|
|
4842
|
+
cfg.jwtToken = cfg.oauth.accessToken;
|
|
4843
|
+
saveConfig(cfg);
|
|
4844
|
+
const me = await fetchMe();
|
|
4845
|
+
print.success(me ? `Logged in as ${meLabel(me)} (${how})` : 'Logged in! Session saved to ' + CONFIG_FILE);
|
|
4846
|
+
};
|
|
4847
|
+
|
|
4848
|
+
if (!useDevice) {
|
|
4758
4849
|
try {
|
|
4759
4850
|
const tokens = await loopbackLogin({ apiBase: cfg.baseUrl, log: (line) => console.log(line) });
|
|
4760
|
-
|
|
4761
|
-
cfg.jwtToken = cfg.oauth.accessToken;
|
|
4762
|
-
saveConfig(cfg);
|
|
4763
|
-
const me = await fetchMe();
|
|
4764
|
-
print.success(me ? `Logged in as ${meLabel(me)} (OAuth 2.1)` : 'Logged in! Session saved to ' + CONFIG_FILE);
|
|
4851
|
+
await save(tokens, 'OAuth 2.1');
|
|
4765
4852
|
return;
|
|
4766
4853
|
} catch (err) {
|
|
4767
4854
|
print.error(`Browser sign-in failed: ${err.message}`);
|
|
@@ -4769,75 +4856,54 @@ async function handleLogin(flags = {}) {
|
|
|
4769
4856
|
process.exit(1);
|
|
4770
4857
|
}
|
|
4771
4858
|
}
|
|
4772
|
-
const cfg = loadConfig();
|
|
4773
|
-
const base = (cfg.baseUrl || 'https://coinpayportal.com/api').replace(/\/$/, '');
|
|
4774
4859
|
|
|
4775
|
-
let start;
|
|
4776
4860
|
try {
|
|
4777
|
-
const
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4861
|
+
const tokens = await deviceLogin({
|
|
4862
|
+
apiBase: cfg.baseUrl,
|
|
4863
|
+
prompt: ({ userCode, verificationUri }) => {
|
|
4864
|
+
// No pre-filled link (verification_uri_complete): typing the code is
|
|
4865
|
+
// what ties the approval to this terminal.
|
|
4866
|
+
console.log('');
|
|
4867
|
+
console.log(' To sign in, open this page on any device where you are signed in to CoinPay:');
|
|
4868
|
+
console.log('');
|
|
4869
|
+
console.log(' ' + colors.cyan + verificationUri + colors.reset);
|
|
4870
|
+
console.log('');
|
|
4871
|
+
console.log(' and enter this code: ' + colors.bright + userCode + colors.reset);
|
|
4872
|
+
console.log('');
|
|
4873
|
+
console.log(colors.yellow + ' Only enter a code your own terminal printed.' + colors.reset);
|
|
4874
|
+
console.log('');
|
|
4875
|
+
print.info('Waiting for approval… (Ctrl-C to cancel)');
|
|
4876
|
+
},
|
|
4781
4877
|
});
|
|
4782
|
-
|
|
4783
|
-
start = await res.json();
|
|
4878
|
+
await save(tokens, 'OAuth 2.1 device flow');
|
|
4784
4879
|
} catch (err) {
|
|
4785
|
-
print.error(
|
|
4880
|
+
if (err.code === 'access_denied') print.error('Request denied.');
|
|
4881
|
+
else if (err.code === 'expired_token') print.error('The code expired. Run `coinpay login --device` again.');
|
|
4882
|
+
else print.error(`Couldn't sign in: ${err.message}`);
|
|
4786
4883
|
process.exit(1);
|
|
4787
4884
|
}
|
|
4788
|
-
|
|
4789
|
-
// G-1.2-10: there is no pre-filled approval link any more. A link that filled
|
|
4790
|
-
// in the code turned a phishing message into one-click account takeover —
|
|
4791
|
-
// anyone could start a device authorization and send the resulting link to a
|
|
4792
|
-
// signed-in merchant. Entering the code by hand is what ties the approval to
|
|
4793
|
-
// a terminal the person is actually sitting at.
|
|
4794
|
-
print.info('');
|
|
4795
|
-
console.log(' To log in, open this URL on any device (e.g. your desktop):');
|
|
4796
|
-
console.log('');
|
|
4797
|
-
console.log(' ' + colors.cyan + start.verification_uri + colors.reset);
|
|
4798
|
-
console.log('');
|
|
4799
|
-
console.log(' and enter this code: ' + colors.bright + start.user_code + colors.reset);
|
|
4800
|
-
console.log('');
|
|
4801
|
-
console.log(colors.yellow + ' Only enter a code your own terminal printed.' + colors.reset);
|
|
4802
|
-
console.log('');
|
|
4803
|
-
print.info('Waiting for approval… (Ctrl-C to cancel)');
|
|
4804
|
-
|
|
4805
|
-
const deadline = Date.now() + (start.expires_in || 600) * 1000;
|
|
4806
|
-
const intervalMs = Math.max(1, start.interval || 5) * 1000;
|
|
4807
|
-
while (Date.now() < deadline) {
|
|
4808
|
-
await new Promise((r) => setTimeout(r, intervalMs));
|
|
4809
|
-
let data = {};
|
|
4810
|
-
try {
|
|
4811
|
-
const res = await fetch(`${base}/cli-auth/poll`, {
|
|
4812
|
-
method: 'POST',
|
|
4813
|
-
headers: { 'content-type': 'application/json' },
|
|
4814
|
-
body: JSON.stringify({ device_code: start.device_code }),
|
|
4815
|
-
});
|
|
4816
|
-
if (res.status === 202) continue; // authorization_pending
|
|
4817
|
-
data = await res.json().catch(() => ({}));
|
|
4818
|
-
} catch {
|
|
4819
|
-
continue; // transient network error — keep polling
|
|
4820
|
-
}
|
|
4821
|
-
if (data.status === 'complete' && data.token) {
|
|
4822
|
-
cfg.jwtToken = data.token;
|
|
4823
|
-
saveConfig(cfg);
|
|
4824
|
-
const me = await fetchMe();
|
|
4825
|
-
print.success(me ? `Logged in as ${meLabel(me)}` : 'Logged in! Session saved to ' + CONFIG_FILE);
|
|
4826
|
-
return;
|
|
4827
|
-
}
|
|
4828
|
-
if (data.status === 'denied') { print.error('Request denied.'); process.exit(1); }
|
|
4829
|
-
if (data.status === 'expired') { print.error('Login request expired — run `coinpay login` again.'); process.exit(1); }
|
|
4830
|
-
}
|
|
4831
|
-
print.error('Timed out waiting for approval.');
|
|
4832
|
-
process.exit(1);
|
|
4833
4885
|
}
|
|
4834
4886
|
|
|
4835
|
-
|
|
4887
|
+
// `coinpay logout`: revoke the grant at CoinPay (RFC 7009; revoking the
|
|
4888
|
+
// refresh token revokes its access tokens too), then forget it locally.
|
|
4889
|
+
async function handleLogout() {
|
|
4836
4890
|
const cfg = loadConfig();
|
|
4891
|
+
if (cfg.oauth) {
|
|
4892
|
+
const { revokeToken, CLI_CLIENT_ID } = await import('../src/oauth-login.js');
|
|
4893
|
+
const clientId = cfg.oauth.clientId || CLI_CLIENT_ID;
|
|
4894
|
+
let ok = false;
|
|
4895
|
+
if (cfg.oauth.refreshToken) {
|
|
4896
|
+
ok = await revokeToken({ apiBase: cfg.baseUrl, token: cfg.oauth.refreshToken, tokenTypeHint: 'refresh_token', clientId });
|
|
4897
|
+
}
|
|
4898
|
+
if (cfg.oauth.accessToken) {
|
|
4899
|
+
ok = (await revokeToken({ apiBase: cfg.baseUrl, token: cfg.oauth.accessToken, tokenTypeHint: 'access_token', clientId })) || ok;
|
|
4900
|
+
}
|
|
4901
|
+
if (!ok) print.warn('Could not reach CoinPay to revoke the session; it is removed locally and expires on its own.');
|
|
4902
|
+
}
|
|
4837
4903
|
delete cfg.jwtToken;
|
|
4838
4904
|
delete cfg.oauth;
|
|
4839
4905
|
saveConfig(cfg);
|
|
4840
|
-
print.success('Logged out — session removed from ' + CONFIG_FILE);
|
|
4906
|
+
print.success('Logged out — session revoked and removed from ' + CONFIG_FILE);
|
|
4841
4907
|
}
|
|
4842
4908
|
|
|
4843
4909
|
// Fetch the signed-in merchant (via the stored session token), or null if not
|
|
@@ -5505,7 +5571,7 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
5505
5571
|
break;
|
|
5506
5572
|
|
|
5507
5573
|
case 'logout':
|
|
5508
|
-
handleLogout();
|
|
5574
|
+
await handleLogout();
|
|
5509
5575
|
break;
|
|
5510
5576
|
|
|
5511
5577
|
case 'whoami':
|
|
@@ -5563,6 +5629,10 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
5563
5629
|
case 'business':
|
|
5564
5630
|
await handleBusiness(subcommand, args, flags);
|
|
5565
5631
|
break;
|
|
5632
|
+
|
|
5633
|
+
case 'vault':
|
|
5634
|
+
await handleVault(subcommand, args, flags);
|
|
5635
|
+
break;
|
|
5566
5636
|
|
|
5567
5637
|
case 'rates':
|
|
5568
5638
|
await handleRates(subcommand, args, flags);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "CoinPay SDK & CLI — Accept cryptocurrency and card payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet, swap and a live finances dashboard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
"import": "./src/wallet.js",
|
|
34
34
|
"types": "./src/wallet.d.ts"
|
|
35
35
|
},
|
|
36
|
+
"./agents": {
|
|
37
|
+
"import": "./src/agents.js",
|
|
38
|
+
"types": "./src/agents.d.ts"
|
|
39
|
+
},
|
|
36
40
|
"./swap": {
|
|
37
41
|
"import": "./src/swap.js",
|
|
38
42
|
"types": "./src/swap.d.ts"
|
package/src/agents.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CoinPayClient } from './client.js';
|
|
2
|
+
import type { AgentWalletLimits } from './client.js';
|
|
3
|
+
|
|
4
|
+
export interface AgentWalletInput extends AgentWalletLimits {
|
|
5
|
+
name: string;
|
|
6
|
+
address: string;
|
|
7
|
+
network?: string;
|
|
8
|
+
status?: 'active' | 'paused' | 'revoked';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export declare class AgentWalletClient {
|
|
12
|
+
constructor(client: CoinPayClient);
|
|
13
|
+
list(): Promise<Record<string, unknown>>;
|
|
14
|
+
create(input: AgentWalletInput): Promise<Record<string, unknown>>;
|
|
15
|
+
update(id: string, patch: Partial<AgentWalletInput>): Promise<Record<string, unknown>>;
|
|
16
|
+
pause(id: string): Promise<Record<string, unknown>>;
|
|
17
|
+
resume(id: string): Promise<Record<string, unknown>>;
|
|
18
|
+
revoke(id: string): Promise<Record<string, unknown>>;
|
|
19
|
+
}
|
package/src/agents.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Mercury-style capped agent wallets. */
|
|
2
|
+
export class AgentWalletClient {
|
|
3
|
+
#client;
|
|
4
|
+
constructor(client) { this.#client = client; }
|
|
5
|
+
list() { return this.#client.request('/v1/agents'); }
|
|
6
|
+
create(input) { return this.#client.request('/v1/agents', { method: 'POST', body: JSON.stringify(input) }); }
|
|
7
|
+
update(id, patch) { return this.#client.request(`/v1/agents/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(patch) }); }
|
|
8
|
+
pause(id) { return this.update(id, { status: 'paused' }); }
|
|
9
|
+
resume(id) { return this.update(id, { status: 'active' }); }
|
|
10
|
+
revoke(id) { return this.#client.request(`/v1/agents/${encodeURIComponent(id)}`, { method: 'DELETE' }); }
|
|
11
|
+
}
|
package/src/client.d.ts
CHANGED
|
@@ -13,6 +13,17 @@ export interface CoinPayClientOptions {
|
|
|
13
13
|
timeout?: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface AgentWalletLimits {
|
|
17
|
+
perPaymentUsd?: number | null;
|
|
18
|
+
hourlyLimitUsd?: number | null;
|
|
19
|
+
dailyLimitUsd?: number | null;
|
|
20
|
+
weeklyLimitUsd?: number | null;
|
|
21
|
+
monthlyLimitUsd?: number | null;
|
|
22
|
+
quarterlyLimitUsd?: number | null;
|
|
23
|
+
yearlyLimitUsd?: number | null;
|
|
24
|
+
totalLimitUsd?: number | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
/** Parameters for creating a payment */
|
|
17
28
|
export interface PaymentParams {
|
|
18
29
|
/** Business ID from your CoinPay dashboard */
|
package/src/client.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { LightningClient } from './lightning.js';
|
|
7
|
+
import { AgentWalletClient } from './agents.js';
|
|
7
8
|
|
|
8
9
|
const DEFAULT_BASE_URL = 'https://coinpayportal.com/api';
|
|
9
10
|
|
|
@@ -29,6 +30,8 @@ export class CoinPayClient {
|
|
|
29
30
|
|
|
30
31
|
/** @type {LightningClient} Lightning/BOLT12 operations */
|
|
31
32
|
this.lightning = new LightningClient(this);
|
|
33
|
+
/** Capped wallets for bots and agent fleets. */
|
|
34
|
+
this.agents = new AgentWalletClient(this);
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/** The API base this client talks to (read-only). */
|
package/src/index.d.ts
CHANGED
package/src/index.js
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
import { CoinPayClient } from './client.js';
|
|
40
|
+
import { AgentWalletClient } from './agents.js';
|
|
40
41
|
import {
|
|
41
42
|
createPayment,
|
|
42
43
|
getPayment,
|
|
@@ -262,6 +263,7 @@ import {
|
|
|
262
263
|
} from './finances.js';
|
|
263
264
|
|
|
264
265
|
export {
|
|
266
|
+
AgentWalletClient,
|
|
265
267
|
// Client
|
|
266
268
|
CoinPayClient,
|
|
267
269
|
|
package/src/oauth-login.js
CHANGED
|
@@ -147,6 +147,99 @@ export async function loopbackLogin({ apiBase, open = openInBrowser, log = () =>
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
export const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Is there a browser this process can open? Not over SSH, not on a Linux box
|
|
154
|
+
* with no display. Used to pick loopback PKCE vs the device flow.
|
|
155
|
+
*/
|
|
156
|
+
export function hasLocalBrowser(env = process.env, platform = process.platform) {
|
|
157
|
+
if (env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT) return false;
|
|
158
|
+
if (env.COINPAY_NO_BROWSER) return false;
|
|
159
|
+
if (platform === 'darwin' || platform === 'win32') return true;
|
|
160
|
+
return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY || env.BROWSER);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* OAuth 2.0 Device Authorization Grant (RFC 8628). Starts at
|
|
165
|
+
* /api/oauth/device_authorization, shows the user_code via `prompt`, and polls
|
|
166
|
+
* /api/oauth/token honouring interval and slow_down. Resolves with the token
|
|
167
|
+
* response; rejects with err.code access_denied / expired_token.
|
|
168
|
+
*/
|
|
169
|
+
export async function deviceLogin({
|
|
170
|
+
apiBase,
|
|
171
|
+
clientId = CLI_CLIENT_ID,
|
|
172
|
+
scope = CLI_SCOPE,
|
|
173
|
+
prompt = () => {},
|
|
174
|
+
fetchImpl = fetch,
|
|
175
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
176
|
+
now = () => Date.now(),
|
|
177
|
+
} = {}) {
|
|
178
|
+
const origin = siteOrigin(apiBase);
|
|
179
|
+
const res = await fetchImpl(new URL('/api/oauth/device_authorization', origin), {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
182
|
+
body: new URLSearchParams({ client_id: clientId, scope }).toString(),
|
|
183
|
+
});
|
|
184
|
+
const start = await res.json().catch(() => ({}));
|
|
185
|
+
if (!res.ok || !start.device_code) {
|
|
186
|
+
const err = new Error(start.error_description || start.error || `device authorization answered ${res.status}`);
|
|
187
|
+
err.code = start.error || 'device_error';
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
prompt({ userCode: start.user_code, verificationUri: start.verification_uri, expiresIn: start.expires_in });
|
|
191
|
+
|
|
192
|
+
let interval = Math.max(1, Number(start.interval) || 5);
|
|
193
|
+
const deadline = now() + (Number(start.expires_in) || 600) * 1000;
|
|
194
|
+
while (now() < deadline) {
|
|
195
|
+
await sleep(interval * 1000);
|
|
196
|
+
let data = {};
|
|
197
|
+
let status = 0;
|
|
198
|
+
try {
|
|
199
|
+
const r = await fetchImpl(new URL('/api/oauth/token', origin), {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
202
|
+
body: new URLSearchParams({ grant_type: DEVICE_GRANT, device_code: start.device_code, client_id: clientId }).toString(),
|
|
203
|
+
});
|
|
204
|
+
status = r.status;
|
|
205
|
+
data = await r.json().catch(() => ({}));
|
|
206
|
+
} catch {
|
|
207
|
+
continue; // network blip: keep polling
|
|
208
|
+
}
|
|
209
|
+
if (status === 200 && data.access_token) return data;
|
|
210
|
+
if (data.error === 'authorization_pending') continue;
|
|
211
|
+
if (data.error === 'slow_down') {
|
|
212
|
+
interval += 5;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const err = new Error(
|
|
216
|
+
data.error === 'access_denied' ? 'access denied' : data.error === 'expired_token' ? 'the code expired' : data.error_description || data.error || `token endpoint answered ${status}`
|
|
217
|
+
);
|
|
218
|
+
err.code = data.error || 'token_error';
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
const err = new Error('the code expired');
|
|
222
|
+
err.code = 'expired_token';
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** RFC 7009: revoke a token at CoinPay. Best effort; resolves true on 200. */
|
|
227
|
+
export async function revokeToken({ apiBase, token, tokenTypeHint, clientId = CLI_CLIENT_ID, fetchImpl = fetch }) {
|
|
228
|
+
if (!token) return false;
|
|
229
|
+
try {
|
|
230
|
+
const params = { token, client_id: clientId };
|
|
231
|
+
if (tokenTypeHint) params.token_type_hint = tokenTypeHint;
|
|
232
|
+
const res = await fetchImpl(new URL('/api/oauth/revoke', siteOrigin(apiBase)), {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
235
|
+
body: new URLSearchParams(params).toString(),
|
|
236
|
+
});
|
|
237
|
+
return res.status === 200;
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
150
243
|
/**
|
|
151
244
|
* Before any command: if the stored OAuth access token is within a minute of
|
|
152
245
|
* expiring, rotate it. Mutates and saves `cfg`. Returns false when the
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merchant secrets vault, from the CLI.
|
|
3
|
+
*
|
|
4
|
+
* The same scheme as the dashboard's /dashboard/vault (src/lib/vault/sealed-vault.ts,
|
|
5
|
+
* WebCrypto only, so this is a line-for-line port): the passphrase stays on this
|
|
6
|
+
* machine. Create builds the vault record here and sends only the record; unlock
|
|
7
|
+
* derives the data key (DEK) here and sends only the DEK, which the server checks
|
|
8
|
+
* against key_check and keeps in memory for a TTL (default 8 h, max 168 h).
|
|
9
|
+
*
|
|
10
|
+
* passphrase --PBKDF2-SHA256 600k--> ikm --HKDF--> KEK
|
|
11
|
+
* KEK wraps a random 32-byte DEK -> wrapped_dek
|
|
12
|
+
* recovery key --HKDF--> RKEK wraps the same DEK -> recovery_wrapped_dek
|
|
13
|
+
* DEK seals a fixed string -> key_check
|
|
14
|
+
* DEK seals a P-256 private key (pkcs8) -> private_key_sealed
|
|
15
|
+
*
|
|
16
|
+
* The API refuses apps signed in with CoinPay (OAuth), so these calls need a
|
|
17
|
+
* business API key (COINPAY_API_KEY or `coinpay config set-key`); the vault they
|
|
18
|
+
* reach is the key owner's.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const PBKDF2_ITERATIONS = 600_000;
|
|
22
|
+
export const MIN_PASSPHRASE_LENGTH = 10;
|
|
23
|
+
const KEY_CHECK_TEXT = 'coinpay-vault-key-check-v1';
|
|
24
|
+
const IV_BYTES = 12;
|
|
25
|
+
const B32 = 'ABCDEFGHJKMNPQRSTVWXYZ0123456789';
|
|
26
|
+
|
|
27
|
+
const enc = new TextEncoder();
|
|
28
|
+
|
|
29
|
+
function subtle() {
|
|
30
|
+
const c = globalThis.crypto;
|
|
31
|
+
if (!c?.subtle) throw new Error('WebCrypto (crypto.subtle) is not available: use Node 20 or newer');
|
|
32
|
+
return c.subtle;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const b64 = (bytes) => Buffer.from(bytes).toString('base64');
|
|
36
|
+
const unb64 = (s) => new Uint8Array(Buffer.from(s, 'base64'));
|
|
37
|
+
|
|
38
|
+
function randomBytes(n) {
|
|
39
|
+
const out = new Uint8Array(n);
|
|
40
|
+
globalThis.crypto.getRandomValues(out);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function vaultAad(subjectType, subjectId, field) {
|
|
45
|
+
if (!subjectId) throw new Error('subjectId required');
|
|
46
|
+
return enc.encode(`coinpay:vault:${subjectType}:${subjectId}:${field}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function gcmEncrypt(key, plaintext, ad) {
|
|
50
|
+
const iv = randomBytes(IV_BYTES);
|
|
51
|
+
const ct = new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv, additionalData: ad }, key, plaintext));
|
|
52
|
+
return { ct: b64(ct), iv: b64(iv) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function gcmDecrypt(key, blob, ad) {
|
|
56
|
+
return new Uint8Array(
|
|
57
|
+
await subtle().decrypt({ name: 'AES-GCM', iv: unb64(blob.iv), additionalData: ad }, key, unb64(blob.ct)),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function aesKey(raw) {
|
|
62
|
+
return subtle().importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function hkdfAesKey(ikm, salt, info) {
|
|
66
|
+
const base = await subtle().importKey('raw', ikm, 'HKDF', false, ['deriveKey']);
|
|
67
|
+
return subtle().deriveKey(
|
|
68
|
+
{ name: 'HKDF', hash: 'SHA-256', salt, info: enc.encode(info) },
|
|
69
|
+
base,
|
|
70
|
+
{ name: 'AES-GCM', length: 256 },
|
|
71
|
+
false,
|
|
72
|
+
['encrypt', 'decrypt'],
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function deriveKek(passphrase, kdf) {
|
|
77
|
+
if (kdf.alg !== 'PBKDF2-SHA256') throw new Error(`unsupported kdf ${kdf.alg}`);
|
|
78
|
+
const salt = unb64(kdf.salt);
|
|
79
|
+
const pw = await subtle().importKey('raw', enc.encode(passphrase.normalize('NFKC')), 'PBKDF2', false, ['deriveBits']);
|
|
80
|
+
const bits = new Uint8Array(
|
|
81
|
+
await subtle().deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: kdf.iterations }, pw, 256),
|
|
82
|
+
);
|
|
83
|
+
try {
|
|
84
|
+
return await hkdfAesKey(bits, salt, 'coinpay/vault/kek/v1');
|
|
85
|
+
} finally {
|
|
86
|
+
bits.fill(0);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function encodeRecoveryKey(raw) {
|
|
91
|
+
let bits = 0;
|
|
92
|
+
let value = 0;
|
|
93
|
+
let out = '';
|
|
94
|
+
for (const b of raw) {
|
|
95
|
+
value = ((value << 8) | b) & 0xffff;
|
|
96
|
+
bits += 8;
|
|
97
|
+
while (bits >= 5) {
|
|
98
|
+
out += B32[(value >>> (bits - 5)) & 31];
|
|
99
|
+
bits -= 5;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
|
|
103
|
+
return out.match(/.{1,4}/g).join('-');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function decodeRecoveryKey(text) {
|
|
107
|
+
const clean = text.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
108
|
+
let bits = 0;
|
|
109
|
+
let value = 0;
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const ch of clean) {
|
|
112
|
+
const idx = B32.indexOf(ch);
|
|
113
|
+
if (idx < 0) throw new Error('invalid recovery key');
|
|
114
|
+
value = ((value << 5) | idx) & 0xffff;
|
|
115
|
+
bits += 5;
|
|
116
|
+
if (bits >= 8) {
|
|
117
|
+
out.push((value >>> (bits - 8)) & 0xff);
|
|
118
|
+
bits -= 8;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (out.length !== 32) throw new Error('invalid recovery key');
|
|
122
|
+
return new Uint8Array(out);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function deriveRecoveryKek(recoveryKey) {
|
|
126
|
+
const raw = decodeRecoveryKey(recoveryKey);
|
|
127
|
+
try {
|
|
128
|
+
return await hkdfAesKey(raw, enc.encode('coinpay/vault/recovery'), 'coinpay/vault/recovery-kek/v1');
|
|
129
|
+
} finally {
|
|
130
|
+
raw.fill(0);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Build a vault record. Returns { record, dek, recoveryKey }; only `record` goes to the server. */
|
|
135
|
+
export async function createVaultRecord(passphrase, subjectId, { subjectType = 'merchant', withRecoveryKey = true } = {}) {
|
|
136
|
+
if (typeof passphrase !== 'string' || passphrase.length < MIN_PASSPHRASE_LENGTH) {
|
|
137
|
+
throw new Error(`passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`);
|
|
138
|
+
}
|
|
139
|
+
const kdf = { alg: 'PBKDF2-SHA256', iterations: PBKDF2_ITERATIONS, salt: b64(randomBytes(16)) };
|
|
140
|
+
const kek = await deriveKek(passphrase, kdf);
|
|
141
|
+
const dek = randomBytes(32);
|
|
142
|
+
const dekKey = await aesKey(dek);
|
|
143
|
+
const pair = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
|
144
|
+
const publicRaw = new Uint8Array(await subtle().exportKey('raw', pair.publicKey));
|
|
145
|
+
const pkcs8 = new Uint8Array(await subtle().exportKey('pkcs8', pair.privateKey));
|
|
146
|
+
|
|
147
|
+
let recoveryKey = null;
|
|
148
|
+
let recoveryWrapped = null;
|
|
149
|
+
if (withRecoveryKey) {
|
|
150
|
+
const raw = randomBytes(32);
|
|
151
|
+
recoveryKey = encodeRecoveryKey(raw);
|
|
152
|
+
raw.fill(0);
|
|
153
|
+
recoveryWrapped = await gcmEncrypt(await deriveRecoveryKek(recoveryKey), dek, vaultAad(subjectType, subjectId, 'recovery_dek'));
|
|
154
|
+
}
|
|
155
|
+
const record = {
|
|
156
|
+
v: 1,
|
|
157
|
+
kdf,
|
|
158
|
+
wrapped_dek: await gcmEncrypt(kek, dek, vaultAad(subjectType, subjectId, 'dek')),
|
|
159
|
+
recovery_wrapped_dek: recoveryWrapped,
|
|
160
|
+
key_check: await gcmEncrypt(dekKey, enc.encode(KEY_CHECK_TEXT), vaultAad(subjectType, subjectId, 'key_check')),
|
|
161
|
+
public_key: b64(publicRaw),
|
|
162
|
+
private_key_sealed: await gcmEncrypt(dekKey, pkcs8, vaultAad(subjectType, subjectId, 'private_key')),
|
|
163
|
+
};
|
|
164
|
+
pkcs8.fill(0);
|
|
165
|
+
return { record, dek, recoveryKey };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Passphrase (or recovery key) -> DEK, checked against key_check before anything is sent. */
|
|
169
|
+
export async function unwrapDek(record, subjectId, { passphrase, recoveryKey, subjectType = 'merchant' } = {}) {
|
|
170
|
+
let dek;
|
|
171
|
+
if (recoveryKey) {
|
|
172
|
+
if (!record.recovery_wrapped_dek) throw new Error('This vault has no recovery key');
|
|
173
|
+
try {
|
|
174
|
+
dek = await gcmDecrypt(await deriveRecoveryKek(recoveryKey), record.recovery_wrapped_dek, vaultAad(subjectType, subjectId, 'recovery_dek'));
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error('Wrong recovery key');
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
try {
|
|
180
|
+
dek = await gcmDecrypt(await deriveKek(passphrase ?? '', record.kdf), record.wrapped_dek, vaultAad(subjectType, subjectId, 'dek'));
|
|
181
|
+
} catch {
|
|
182
|
+
throw new Error('Wrong passphrase');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const check = await gcmDecrypt(await aesKey(dek), record.key_check, vaultAad(subjectType, subjectId, 'key_check'));
|
|
186
|
+
if (new TextDecoder().decode(check) !== KEY_CHECK_TEXT) throw new Error('Vault key check failed');
|
|
187
|
+
return dek;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** GET /vault: { subject_id, exists, record, unlocked, expiresAt }. */
|
|
191
|
+
export function getVault(client) {
|
|
192
|
+
return client.request('/vault', { headers: { 'Cache-Control': 'no-store' } });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Create the vault and unlock it right away. Returns { recoveryKey, migrated, expiresAt }. */
|
|
196
|
+
export async function initVault(client, { passphrase, withRecoveryKey = true, ttlHours = 8 } = {}) {
|
|
197
|
+
const status = await getVault(client);
|
|
198
|
+
if (status.exists) throw new Error('This account already has a secrets vault: use `coinpay vault unlock`');
|
|
199
|
+
const { record, dek, recoveryKey } = await createVaultRecord(passphrase, status.subject_id, { withRecoveryKey });
|
|
200
|
+
const created = await client.request('/vault', { method: 'POST', body: JSON.stringify({ record }) });
|
|
201
|
+
try {
|
|
202
|
+
const unlocked = await client.request('/vault/unlock', {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
body: JSON.stringify({ data_key: b64(dek), ttl_hours: ttlHours }),
|
|
205
|
+
});
|
|
206
|
+
return { recoveryKey, migrated: created.migrated ?? null, expiresAt: unlocked.expiresAt };
|
|
207
|
+
} finally {
|
|
208
|
+
dek.fill(0);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Unlock with the passphrase or recovery key for ttlHours (1..168). */
|
|
213
|
+
export async function unlockVault(client, { passphrase, recoveryKey, ttlHours = 8 } = {}) {
|
|
214
|
+
const status = await getVault(client);
|
|
215
|
+
if (!status.exists) throw new Error('No secrets vault yet: run `coinpay vault init`');
|
|
216
|
+
const dek = await unwrapDek(status.record, status.subject_id, { passphrase, recoveryKey });
|
|
217
|
+
try {
|
|
218
|
+
return await client.request('/vault/unlock', {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
body: JSON.stringify({ data_key: b64(dek), ttl_hours: ttlHours }),
|
|
221
|
+
});
|
|
222
|
+
} finally {
|
|
223
|
+
dek.fill(0);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function lockVault(client) {
|
|
228
|
+
return client.request('/vault/lock', { method: 'POST', body: '{}' });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** GET (create=false) or POST (create=true) /businesses/:id/payout-wallet. */
|
|
232
|
+
export function payoutWallet(client, businessId, { create = false } = {}) {
|
|
233
|
+
return client.request(`/businesses/${encodeURIComponent(businessId)}/payout-wallet`, create ? { method: 'POST', body: '{}' } : {});
|
|
234
|
+
}
|