@profullstack/coinpay 0.27.1 → 0.29.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 +23 -0
- package/bin/coinpay.js +58 -73
- package/package.json +1 -1
- package/src/finances-commands.js +31 -5
- package/src/finances-reports.d.ts +14 -0
- package/src/mcp.js +30 -0
- package/src/oauth-login.js +93 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ 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
|
+
|
|
19
|
+
## [0.28.0] - 2026-10-29
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- `coinpay finances goals edit <id>` changes any field of a goal (`--name`, `--kind`, `--target`,
|
|
23
|
+
`--deadline`, `--start`, `--start-amount n|none`, `--notes "..."|none`) and which accounts or wallets
|
|
24
|
+
count (`--account <id>,<id>` replaces the list, `--account none` unlinks all).
|
|
25
|
+
- `coinpay finances goals unarchive <id>`. Archived goals are hidden from `goals list` (unless
|
|
26
|
+
`--archived`) and left out of the report and the goals email.
|
|
27
|
+
- `goals show` prints what the progress is made of: every linked account with its amount, flagged when
|
|
28
|
+
its balance is over a week old. `FinanceGoal.sources` / `GoalSource` in the types.
|
|
29
|
+
- MCP: `finance_goal_update` (edit, relink, archive/unarchive) and `finance_goal_delete`.
|
|
30
|
+
|
|
8
31
|
## [0.27.1] - 2026-10-28
|
|
9
32
|
|
|
10
33
|
### Fixed
|
package/bin/coinpay.js
CHANGED
|
@@ -500,8 +500,8 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
500
500
|
set-url <base-url> Set custom API URL
|
|
501
501
|
show Show current configuration
|
|
502
502
|
|
|
503
|
-
${colors.bright}login${colors.reset} Sign in (OAuth 2.1
|
|
504
|
-
${colors.bright}logout${colors.reset}
|
|
503
|
+
${colors.bright}login${colors.reset} Sign in (OAuth 2.1: browser + PKCE, or the device flow over SSH / --device)
|
|
504
|
+
${colors.bright}logout${colors.reset} Sign out and revoke the session at CoinPay
|
|
505
505
|
|
|
506
506
|
${colors.bright}auth${colors.reset}
|
|
507
507
|
register Register new merchant account
|
|
@@ -4746,22 +4746,28 @@ async function runInteractiveMenu() {
|
|
|
4746
4746
|
}
|
|
4747
4747
|
}
|
|
4748
4748
|
|
|
4749
|
-
//
|
|
4750
|
-
//
|
|
4751
|
-
//
|
|
4749
|
+
// `coinpay login`: OAuth 2.1 as the public client `coinpay-cli`. With a local
|
|
4750
|
+
// browser it uses authorization code + PKCE on a loopback redirect; over SSH
|
|
4751
|
+
// or with no display (or --device) it uses the device flow (RFC 8628): it
|
|
4752
|
+
// prints a code to type at coinpayportal.com/oauth/device on any device.
|
|
4753
|
+
// Either way it stores a rotating refresh token in ~/.coinpay.json.
|
|
4752
4754
|
async function handleLogin(flags = {}) {
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4755
|
+
const cfg = loadConfig();
|
|
4756
|
+
const { loopbackLogin, deviceLogin, hasLocalBrowser, toStoredSession } = await import('../src/oauth-login.js');
|
|
4757
|
+
const useDevice = Boolean(flags.device) || (!flags.browser && !hasLocalBrowser());
|
|
4758
|
+
|
|
4759
|
+
const save = async (tokens, how) => {
|
|
4760
|
+
cfg.oauth = toStoredSession(tokens);
|
|
4761
|
+
cfg.jwtToken = cfg.oauth.accessToken;
|
|
4762
|
+
saveConfig(cfg);
|
|
4763
|
+
const me = await fetchMe();
|
|
4764
|
+
print.success(me ? `Logged in as ${meLabel(me)} (${how})` : 'Logged in! Session saved to ' + CONFIG_FILE);
|
|
4765
|
+
};
|
|
4766
|
+
|
|
4767
|
+
if (!useDevice) {
|
|
4758
4768
|
try {
|
|
4759
4769
|
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);
|
|
4770
|
+
await save(tokens, 'OAuth 2.1');
|
|
4765
4771
|
return;
|
|
4766
4772
|
} catch (err) {
|
|
4767
4773
|
print.error(`Browser sign-in failed: ${err.message}`);
|
|
@@ -4769,75 +4775,54 @@ async function handleLogin(flags = {}) {
|
|
|
4769
4775
|
process.exit(1);
|
|
4770
4776
|
}
|
|
4771
4777
|
}
|
|
4772
|
-
const cfg = loadConfig();
|
|
4773
|
-
const base = (cfg.baseUrl || 'https://coinpayportal.com/api').replace(/\/$/, '');
|
|
4774
4778
|
|
|
4775
|
-
let start;
|
|
4776
4779
|
try {
|
|
4777
|
-
const
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4780
|
+
const tokens = await deviceLogin({
|
|
4781
|
+
apiBase: cfg.baseUrl,
|
|
4782
|
+
prompt: ({ userCode, verificationUri }) => {
|
|
4783
|
+
// No pre-filled link (verification_uri_complete): typing the code is
|
|
4784
|
+
// what ties the approval to this terminal.
|
|
4785
|
+
console.log('');
|
|
4786
|
+
console.log(' To sign in, open this page on any device where you are signed in to CoinPay:');
|
|
4787
|
+
console.log('');
|
|
4788
|
+
console.log(' ' + colors.cyan + verificationUri + colors.reset);
|
|
4789
|
+
console.log('');
|
|
4790
|
+
console.log(' and enter this code: ' + colors.bright + userCode + colors.reset);
|
|
4791
|
+
console.log('');
|
|
4792
|
+
console.log(colors.yellow + ' Only enter a code your own terminal printed.' + colors.reset);
|
|
4793
|
+
console.log('');
|
|
4794
|
+
print.info('Waiting for approval… (Ctrl-C to cancel)');
|
|
4795
|
+
},
|
|
4781
4796
|
});
|
|
4782
|
-
|
|
4783
|
-
start = await res.json();
|
|
4797
|
+
await save(tokens, 'OAuth 2.1 device flow');
|
|
4784
4798
|
} catch (err) {
|
|
4785
|
-
print.error(
|
|
4799
|
+
if (err.code === 'access_denied') print.error('Request denied.');
|
|
4800
|
+
else if (err.code === 'expired_token') print.error('The code expired. Run `coinpay login --device` again.');
|
|
4801
|
+
else print.error(`Couldn't sign in: ${err.message}`);
|
|
4786
4802
|
process.exit(1);
|
|
4787
4803
|
}
|
|
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
4804
|
}
|
|
4834
4805
|
|
|
4835
|
-
|
|
4806
|
+
// `coinpay logout`: revoke the grant at CoinPay (RFC 7009; revoking the
|
|
4807
|
+
// refresh token revokes its access tokens too), then forget it locally.
|
|
4808
|
+
async function handleLogout() {
|
|
4836
4809
|
const cfg = loadConfig();
|
|
4810
|
+
if (cfg.oauth) {
|
|
4811
|
+
const { revokeToken, CLI_CLIENT_ID } = await import('../src/oauth-login.js');
|
|
4812
|
+
const clientId = cfg.oauth.clientId || CLI_CLIENT_ID;
|
|
4813
|
+
let ok = false;
|
|
4814
|
+
if (cfg.oauth.refreshToken) {
|
|
4815
|
+
ok = await revokeToken({ apiBase: cfg.baseUrl, token: cfg.oauth.refreshToken, tokenTypeHint: 'refresh_token', clientId });
|
|
4816
|
+
}
|
|
4817
|
+
if (cfg.oauth.accessToken) {
|
|
4818
|
+
ok = (await revokeToken({ apiBase: cfg.baseUrl, token: cfg.oauth.accessToken, tokenTypeHint: 'access_token', clientId })) || ok;
|
|
4819
|
+
}
|
|
4820
|
+
if (!ok) print.warn('Could not reach CoinPay to revoke the session; it is removed locally and expires on its own.');
|
|
4821
|
+
}
|
|
4837
4822
|
delete cfg.jwtToken;
|
|
4838
4823
|
delete cfg.oauth;
|
|
4839
4824
|
saveConfig(cfg);
|
|
4840
|
-
print.success('Logged out — session removed from ' + CONFIG_FILE);
|
|
4825
|
+
print.success('Logged out — session revoked and removed from ' + CONFIG_FILE);
|
|
4841
4826
|
}
|
|
4842
4827
|
|
|
4843
4828
|
// Fetch the signed-in merchant (via the stored session token), or null if not
|
|
@@ -5505,7 +5490,7 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
5505
5490
|
break;
|
|
5506
5491
|
|
|
5507
5492
|
case 'logout':
|
|
5508
|
-
handleLogout();
|
|
5493
|
+
await handleLogout();
|
|
5509
5494
|
break;
|
|
5510
5495
|
|
|
5511
5496
|
case 'whoami':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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",
|
package/src/finances-commands.js
CHANGED
|
@@ -968,7 +968,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
968
968
|
case 'goals':
|
|
969
969
|
case 'goal': {
|
|
970
970
|
const action = args[0] || 'list';
|
|
971
|
-
const usage = 'Usage: coinpay finances goals [list|show <id>|create --name "Down payment" --kind house|car|emergency_fund|debt_payoff|custom --target 60000 --deadline 2031-06-30 [--start-amount 5000] [--account <id>,<id>] [--wallet <id>]|contribute <id> <amount> [--date 2026-10-01] [--note "..."]|report [--period daily|weekly|monthly|quarterly]|digest [show|on --cadence monthly [--hour 8]|off|send-now]|sources|archive <id>|delete <id> --yes]';
|
|
971
|
+
const usage = 'Usage: coinpay finances goals [list|show <id>|create --name "Down payment" --kind house|car|emergency_fund|debt_payoff|custom --target 60000 --deadline 2031-06-30 [--start-amount 5000] [--account <id>,<id>] [--wallet <id>]|contribute <id> <amount> [--date 2026-10-01] [--note "..."]|report [--period daily|weekly|monthly|quarterly]|digest [show|on --cadence monthly [--hour 8]|off|send-now]|edit <id> [--name ..] [--target ..] [--deadline ..] [--account <id>,<id>|none] ...|sources|archive <id>|unarchive <id>|delete <id> --yes]';
|
|
972
972
|
const money = (cur, n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: cur || 'USD', maximumFractionDigits: 2 }).format(n);
|
|
973
973
|
const STATUS = { achieved: 'achieved', on_track: 'on track', behind: 'BEHIND', overdue: 'PAST DEADLINE' };
|
|
974
974
|
const line = (g) => {
|
|
@@ -1000,6 +1000,10 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
1000
1000
|
` To finish on time: ${money(g.currency, s.required.perDay)}/day · ${money(g.currency, s.required.perWeek)}/week · ${money(g.currency, s.required.perMonth)}/month · ${money(g.currency, s.required.perQuarter)}/quarter`,
|
|
1001
1001
|
s.recentPerMonth !== null ? ` Last 90 days: ${money(g.currency, s.recentPerMonth)}/month${s.projectedDate ? `, lands ${s.projectedDate}` : ''}` : null,
|
|
1002
1002
|
data.contributions.length ? ` Contributions: ${data.contributions.slice(0, 10).map((c) => `${c.occurredOn} ${money(g.currency, c.amount)}`).join(', ')}` : null,
|
|
1003
|
+
g.archivedAt ? ` Archived ${g.archivedAt.slice(0, 10)} (unarchive: coinpay finances goals unarchive ${g.id.slice(0, 8)})` : null,
|
|
1004
|
+
...(g.sources?.length
|
|
1005
|
+
? [' Made of:', ...g.sources.map((src) => ` ${(src.counted ? money(g.currency, src.amount) : 'not counted').padStart(12)} ${src.institution && src.type === 'account' ? `${src.institution} · ` : ''}${src.name}${src.id ? ` [${src.id.slice(0, 8)}]` : ''}${src.stale && src.asOf ? ` (balance from ${src.asOf.slice(0, 10)})` : ''}`)]
|
|
1006
|
+
: []),
|
|
1003
1007
|
].filter(Boolean).join('\n');
|
|
1004
1008
|
emit(data, text);
|
|
1005
1009
|
return EXIT.OK;
|
|
@@ -1040,7 +1044,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
1040
1044
|
const data = await api.getGoalSources(client);
|
|
1041
1045
|
emit(data, [
|
|
1042
1046
|
'Accounts:',
|
|
1043
|
-
...data.accounts.map((a) => ` ${a.id} ${(a.institution ? `${a.institution} · ` : '') + a.name}${a.liability ? ' (debt)' : ''}${a.balance !== null ? ` ${money(a.currency, a.balance)}` : ''}`),
|
|
1047
|
+
...data.accounts.map((a) => ` ${a.id} ${(a.institution ? `${a.institution} · ` : '') + a.name}${a.liability ? ' (debt)' : ''}${a.hidden ? ' (hidden)' : ''}${a.balance !== null ? ` ${money(a.currency, a.balance)}` : ''}`),
|
|
1044
1048
|
'Wallets:',
|
|
1045
1049
|
...(data.wallets.length ? data.wallets.map((w) => ` ${w.walletId} ${w.label ?? ''}`) : [' none']),
|
|
1046
1050
|
].join('\n'));
|
|
@@ -1070,9 +1074,31 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
1070
1074
|
}
|
|
1071
1075
|
throw new CliExit(EXIT.INVALID, usage);
|
|
1072
1076
|
}
|
|
1073
|
-
if (action === '
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1077
|
+
if (action === 'edit' || action === 'update') {
|
|
1078
|
+
const id = await resolveId(args[1]);
|
|
1079
|
+
const idsFlag = (v) => (v === 'none' ? [] : listFlag(v));
|
|
1080
|
+
const patch = {
|
|
1081
|
+
name: typeof flags.name === 'string' ? flags.name : undefined,
|
|
1082
|
+
kind: typeof flags.kind === 'string' ? flags.kind : undefined,
|
|
1083
|
+
targetAmount: flags.target !== undefined ? String(flags.target) : undefined,
|
|
1084
|
+
deadline: typeof flags.deadline === 'string' ? flags.deadline : undefined,
|
|
1085
|
+
startDate: typeof flags.start === 'string' ? flags.start : undefined,
|
|
1086
|
+
currency: typeof flags.currency === 'string' ? flags.currency : undefined,
|
|
1087
|
+
startingAmount: flags['start-amount'] === 'none' ? null : flags['start-amount'] !== undefined ? String(flags['start-amount']) : undefined,
|
|
1088
|
+
linkedAccountIds: flags.account ? idsFlag(flags.account) : undefined,
|
|
1089
|
+
linkedWalletIds: flags.wallet ? idsFlag(flags.wallet) : undefined,
|
|
1090
|
+
notes: flags.notes === 'none' ? null : typeof flags.notes === 'string' ? flags.notes : undefined,
|
|
1091
|
+
};
|
|
1092
|
+
for (const k of Object.keys(patch)) if (patch[k] === undefined) delete patch[k];
|
|
1093
|
+
if (Object.keys(patch).length === 0) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances goals edit <id> [--name ..] [--kind ..] [--target ..] [--deadline ..] [--start YYYY-MM-DD] [--start-amount n|none] [--account <id>,<id>|none] [--wallet <id>|none] [--notes "..."|none]');
|
|
1094
|
+
const g = await api.updateGoal(client, id, patch);
|
|
1095
|
+
emit({ goal: g }, `Saved ${g.name}\n${g.snapshot ? line(g) : ''}`);
|
|
1096
|
+
return EXIT.OK;
|
|
1097
|
+
}
|
|
1098
|
+
if (action === 'archive' || action === 'unarchive') {
|
|
1099
|
+
const archived = action === 'archive';
|
|
1100
|
+
const g = await api.updateGoal(client, await resolveId(args[1]), { archived });
|
|
1101
|
+
emit({ goal: g }, archived ? `Archived ${g.name}. It is hidden from goals list and left out of reports (list --archived shows it).` : `${g.name} is active again.`);
|
|
1076
1102
|
return EXIT.OK;
|
|
1077
1103
|
}
|
|
1078
1104
|
if (action === 'delete') {
|
|
@@ -494,8 +494,22 @@ export interface FinanceGoal {
|
|
|
494
494
|
linkedAccountIds: string[];
|
|
495
495
|
linkedWalletIds: string[];
|
|
496
496
|
notes: string | null;
|
|
497
|
+
archivedAt: string | null;
|
|
497
498
|
direction: 'save' | 'payoff';
|
|
498
499
|
snapshot: GoalSnapshot;
|
|
500
|
+
/** What progress is made of; counted amounts sum to `snapshot.progress`. */
|
|
501
|
+
sources: GoalSource[];
|
|
502
|
+
}
|
|
503
|
+
export interface GoalSource {
|
|
504
|
+
type: 'account' | 'wallets' | 'contributions' | 'starting';
|
|
505
|
+
id: string | null;
|
|
506
|
+
name: string;
|
|
507
|
+
institution: string | null;
|
|
508
|
+
kind: string | null;
|
|
509
|
+
amount: number;
|
|
510
|
+
asOf: string | null;
|
|
511
|
+
stale: boolean;
|
|
512
|
+
counted: boolean;
|
|
499
513
|
}
|
|
500
514
|
export interface GoalInput {
|
|
501
515
|
name: string;
|
package/src/mcp.js
CHANGED
|
@@ -107,6 +107,36 @@ export const TOOLS = [
|
|
|
107
107
|
},
|
|
108
108
|
handler: async (args, { client }) => text({ goal: await reports.createGoal(client, args) }),
|
|
109
109
|
},
|
|
110
|
+
{
|
|
111
|
+
name: 'finance_goal_update',
|
|
112
|
+
description: 'Edit a goal (any field), choose which linked accounts/wallets count (the full list replaces the old one; [] unlinks all), or archive/unarchive it. Archived goals are hidden from finance_goals unless archived=true and left out of reports and the goals email. Each goal\'s `sources` lists what its progress is made of.',
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
goalId: { type: 'string' },
|
|
117
|
+
name: { type: 'string' },
|
|
118
|
+
kind: { type: 'string', enum: ['house', 'car', 'emergency_fund', 'debt_payoff', 'custom'] },
|
|
119
|
+
targetAmount: { type: ['number', 'string'] },
|
|
120
|
+
deadline: { type: 'string', description: 'YYYY-MM-DD' },
|
|
121
|
+
startDate: { type: 'string', description: 'YYYY-MM-DD' },
|
|
122
|
+
currency: { type: 'string' },
|
|
123
|
+
startingAmount: { type: ['number', 'string', 'null'] },
|
|
124
|
+
linkedAccountIds: { type: 'array', items: { type: 'string' } },
|
|
125
|
+
linkedWalletIds: { type: 'array', items: { type: 'string' } },
|
|
126
|
+
notes: { type: ['string', 'null'] },
|
|
127
|
+
archived: { type: 'boolean' },
|
|
128
|
+
},
|
|
129
|
+
required: ['goalId'],
|
|
130
|
+
additionalProperties: false,
|
|
131
|
+
},
|
|
132
|
+
handler: async ({ goalId, ...patch }, { client }) => text({ goal: await reports.updateGoal(client, goalId, patch) }),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'finance_goal_delete',
|
|
136
|
+
description: 'Delete a goal and its recorded contributions for good. Prefer finance_goal_update with archived=true to keep it.',
|
|
137
|
+
inputSchema: { type: 'object', properties: { goalId: { type: 'string' } }, required: ['goalId'], additionalProperties: false },
|
|
138
|
+
handler: async (args, { client }) => text(await reports.deleteGoal(client, args.goalId)),
|
|
139
|
+
},
|
|
110
140
|
{
|
|
111
141
|
name: 'finance_goal_contribute',
|
|
112
142
|
description: 'Record an amount put toward a goal, or a payment on a payoff goal (negative = withdrawal). Records only; moves no money.',
|
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
|