@profullstack/coinpay 0.19.0 → 0.21.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 +15 -0
- package/bin/coinpay.js +178 -1
- package/package.json +1 -1
- package/src/documents.d.ts +86 -0
- package/src/documents.js +285 -0
- package/src/finances-commands.js +15 -4
- package/src/finances-reports.js +15 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +31 -0
- package/src/mcp.js +31 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ 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.21.0] - 2026-10-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Documents, end-to-end encrypted: `coinpay document vault-setup|list|upload|share|get|link|revoke|download|void|delete`.
|
|
12
|
+
Files are encrypted on your machine (AES-256-GCM, one key per document, wrapped under the
|
|
13
|
+
business vault key, which is wrapped under a PBKDF2 passphrase); CoinPay stores only ciphertext.
|
|
14
|
+
`share` adds signers and viewers and prints each full link with its `#k=` key; the invite
|
|
15
|
+
emails omit the key unless `--email-key`. `download` decrypts and verifies the sha256.
|
|
16
|
+
Invites can be signers, editors (upload new versions) or viewers (`--signers/--editors/--viewers`).
|
|
17
|
+
Every version is kept: `versions <id>`, `new-version <id> <file>`, `download --revision N`.
|
|
18
|
+
- SDK: `listDocuments`, `getDocument`, `setupDocumentsVault`, `unlockDocumentsVault`,
|
|
19
|
+
`uploadDocument`, `shareDocument`, `newDocumentLink`, `revokeDocumentRecipient`,
|
|
20
|
+
`voidDocument`, `deleteDocument`, `downloadDocument`, `listDocumentVersions`, `uploadDocumentVersion`.
|
|
21
|
+
- MCP: `document_list`, `document_get`, `document_void` (metadata only; no keys).
|
|
22
|
+
|
|
8
23
|
## [0.19.0] - 2026-10-17
|
|
9
24
|
|
|
10
25
|
### Added
|
package/bin/coinpay.js
CHANGED
|
@@ -66,6 +66,21 @@ import {
|
|
|
66
66
|
recordPayRunPayments,
|
|
67
67
|
get1099Summary,
|
|
68
68
|
} from '../src/contractors.js';
|
|
69
|
+
import {
|
|
70
|
+
listDocuments,
|
|
71
|
+
getDocument,
|
|
72
|
+
setupDocumentsVault,
|
|
73
|
+
unlockDocumentsVault,
|
|
74
|
+
uploadDocument,
|
|
75
|
+
shareDocument,
|
|
76
|
+
newDocumentLink,
|
|
77
|
+
revokeDocumentRecipient,
|
|
78
|
+
voidDocument,
|
|
79
|
+
deleteDocument,
|
|
80
|
+
downloadDocument,
|
|
81
|
+
listDocumentVersions,
|
|
82
|
+
uploadDocumentVersion,
|
|
83
|
+
} from '../src/documents.js';
|
|
69
84
|
import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'fs';
|
|
70
85
|
import { execSync, spawn } from 'child_process';
|
|
71
86
|
import { createInterface } from 'readline';
|
|
@@ -534,6 +549,22 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
534
549
|
1099 Totals and who needs a 1099-NEC (--year 2026)
|
|
535
550
|
offboard <id> End contracts and revoke the portal link
|
|
536
551
|
|
|
552
|
+
${colors.bright}document${colors.reset} (end-to-end encrypted: CoinPay never sees the contents)
|
|
553
|
+
vault-setup Create the documents passphrase (--business-id, admins)
|
|
554
|
+
list Documents (--business-id) with signing progress
|
|
555
|
+
upload <file> Encrypt and upload: --business-id [--title] [--message]
|
|
556
|
+
share <id> --signers "Ada <ada@x.com>, bob@y.com" [--editors "..."] [--viewers "..."]
|
|
557
|
+
[--message] [--no-email] [--email-key puts the key in the email]
|
|
558
|
+
Prints each full link (with #k= key): send it privately
|
|
559
|
+
get <id> Status, people and audit trail
|
|
560
|
+
link <id> <recipient> New link for one person (old one stops working)
|
|
561
|
+
revoke <id> <recipient> Remove someone who has not signed
|
|
562
|
+
versions <id> The history: every uploaded version
|
|
563
|
+
new-version <id> <file> Encrypt and upload the next version [--note]
|
|
564
|
+
download <id> Decrypt to a file [--signed | --revision N] [--out path]
|
|
565
|
+
void <id> [--reason] delete <id>
|
|
566
|
+
Passphrase: COINPAY_DOCUMENTS_PASSPHRASE or a hidden prompt
|
|
567
|
+
|
|
537
568
|
${colors.bright}payrun${colors.reset}
|
|
538
569
|
list Pay runs
|
|
539
570
|
create Draft from fixed fees due + approved work [--date YYYY-MM-DD]
|
|
@@ -682,7 +713,7 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
682
713
|
due [--days 7] What is due and overdue across every account (--days up to 60)
|
|
683
714
|
recurring Every recurring charge detected, with next date and monthly cost
|
|
684
715
|
due-day <id> <1-31|clear> A card/loan's statement due day (--amount 810.46, or balance)
|
|
685
|
-
daily [show|on|off|send-now] Daily summary email:
|
|
716
|
+
daily [show|on|off|send-now] Daily summary email: spend vs target, top cuts, due soon, position (--hour 7 --to …, --target 100)
|
|
686
717
|
|
|
687
718
|
${colors.bright}escrow${colors.reset}
|
|
688
719
|
create Create a new escrow
|
|
@@ -1277,6 +1308,147 @@ function usageExit(usage) {
|
|
|
1277
1308
|
process.exit(1);
|
|
1278
1309
|
}
|
|
1279
1310
|
|
|
1311
|
+
/** "Ada <ada@x.com>, bob@y.com" -> [{ name, email }] */
|
|
1312
|
+
function parsePeople(text, role) {
|
|
1313
|
+
return String(text || '')
|
|
1314
|
+
.split(',')
|
|
1315
|
+
.map((s) => s.trim())
|
|
1316
|
+
.filter(Boolean)
|
|
1317
|
+
.map((s) => {
|
|
1318
|
+
const m = /^(.*?)\s*<([^>]+)>$/.exec(s);
|
|
1319
|
+
const email = (m ? m[2] : s).trim();
|
|
1320
|
+
return { name: (m && m[1].trim()) || email, email, role };
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
async function documentsVault(client, businessId) {
|
|
1325
|
+
const passphrase = process.env.COINPAY_DOCUMENTS_PASSPHRASE || (await promptPassword('Documents passphrase: '));
|
|
1326
|
+
return unlockDocumentsVault(client, { businessId, passphrase });
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
async function handleDocument(subcommand, args, flags) {
|
|
1330
|
+
const client = createClient();
|
|
1331
|
+
requireFlagValues(flags, ['business-id', 'title', 'message', 'signers', 'viewers', 'editors', 'reason', 'out', 'note', 'revision']);
|
|
1332
|
+
switch (subcommand) {
|
|
1333
|
+
case 'vault-setup': {
|
|
1334
|
+
if (!flags['business-id']) usageExit('coinpay document vault-setup --business-id <id>');
|
|
1335
|
+
print.warn('If this passphrase is lost, nobody (including CoinPay) can recover the documents.');
|
|
1336
|
+
const passphrase = process.env.COINPAY_DOCUMENTS_PASSPHRASE || (await promptPassword('New documents passphrase: '));
|
|
1337
|
+
if (!process.env.COINPAY_DOCUMENTS_PASSPHRASE && (await promptPassword('Repeat it: ')) !== passphrase) usageExit('The passphrases do not match');
|
|
1338
|
+
const vault = await setupDocumentsVault(client, { businessId: flags['business-id'], passphrase });
|
|
1339
|
+
return flags.json ? print.json({ version: vault.version }) : print.success('Documents vault created (version ' + vault.version + ')');
|
|
1340
|
+
}
|
|
1341
|
+
case 'list': {
|
|
1342
|
+
const rows = await listDocuments(client, { businessId: flags['business-id'] });
|
|
1343
|
+
if (flags.json) return print.json(rows);
|
|
1344
|
+
if (!rows.length) return print.info('No documents');
|
|
1345
|
+
for (const d of rows) {
|
|
1346
|
+
const progress = d.signers ? ' ' + d.signed + '/' + d.signers + ' signed' : '';
|
|
1347
|
+
print.info(d.id + ' ' + d.title + ' ' + d.status + progress);
|
|
1348
|
+
}
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
case 'upload': {
|
|
1352
|
+
if (args.length !== 1 || !flags['business-id']) usageExit('coinpay document upload <file> --business-id <id> [--title "..."] [--message "..."]');
|
|
1353
|
+
const vault = await documentsVault(client, flags['business-id']);
|
|
1354
|
+
const doc = await uploadDocument(client, {
|
|
1355
|
+
businessId: flags['business-id'],
|
|
1356
|
+
vault,
|
|
1357
|
+
bytes: readFileSync(args[0]),
|
|
1358
|
+
filename: args[0],
|
|
1359
|
+
title: flags.title,
|
|
1360
|
+
message: flags.message,
|
|
1361
|
+
});
|
|
1362
|
+
if (flags.json) return print.json(doc);
|
|
1363
|
+
print.success('Uploaded (encrypted): ' + doc.id);
|
|
1364
|
+
return print.info('Send it with: coinpay document share ' + doc.id + ' --signers "Name <email>"');
|
|
1365
|
+
}
|
|
1366
|
+
case 'share': {
|
|
1367
|
+
const recipients = [...parsePeople(flags.signers, 'signer'), ...parsePeople(flags.editors, 'editor'), ...parsePeople(flags.viewers, 'viewer')];
|
|
1368
|
+
if (args.length !== 1 || !recipients.length) usageExit('coinpay document share <id> --signers "Ada <ada@x.com>" [--editors "..."] [--viewers "..."] [--message] [--no-email] [--email-key]');
|
|
1369
|
+
const { document } = await getDocument(client, args[0]);
|
|
1370
|
+
const vault = await documentsVault(client, document.business_id);
|
|
1371
|
+
const result = await shareDocument(client, args[0], {
|
|
1372
|
+
recipients,
|
|
1373
|
+
message: flags.message,
|
|
1374
|
+
sendEmail: !flags['no-email'],
|
|
1375
|
+
emailKey: !!flags['email-key'],
|
|
1376
|
+
vault,
|
|
1377
|
+
});
|
|
1378
|
+
if (flags.json) return print.json(result);
|
|
1379
|
+
print.success('Shared; document is now ' + result.status);
|
|
1380
|
+
if (!flags['email-key']) print.warn('The emails do NOT contain the key. Send each person their full link below on another channel:');
|
|
1381
|
+
for (const r of result.recipients) print.info(r.role.padEnd(6) + ' ' + r.email + ' ' + r.link);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
case 'get': {
|
|
1385
|
+
if (args.length !== 1) usageExit('coinpay document get <id>');
|
|
1386
|
+
const data = await getDocument(client, args[0]);
|
|
1387
|
+
if (flags.json) return print.json(data);
|
|
1388
|
+
const d = data.document;
|
|
1389
|
+
print.info(d.title + ' ' + d.status + ' ' + d.filename + ' (' + d.bytes + ' bytes)');
|
|
1390
|
+
print.info('sha256 ' + d.sha256 + (d.signed_sha256 ? '\nsigned sha256 ' + d.signed_sha256 : ''));
|
|
1391
|
+
for (const r of data.recipients) {
|
|
1392
|
+
if (r.revoked_at) continue;
|
|
1393
|
+
print.info(' ' + r.id + ' ' + r.role + ' ' + r.name + ' <' + r.email + '> ' + r.status + (r.signed_at ? ' ' + r.signed_at : ''));
|
|
1394
|
+
}
|
|
1395
|
+
for (const e of data.events) print.info(' ' + e.created_at + ' ' + e.event + (e.actor_label ? ' ' + e.actor_label : '') + (e.ip ? ' ' + e.ip : ''));
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
case 'link': {
|
|
1399
|
+
if (args.length !== 2) usageExit('coinpay document link <id> <recipient-id> [--no-email] [--email-key]');
|
|
1400
|
+
const { document } = await getDocument(client, args[0]);
|
|
1401
|
+
const vault = await documentsVault(client, document.business_id);
|
|
1402
|
+
const result = await newDocumentLink(client, args[0], args[1], { sendEmail: !flags['no-email'], emailKey: !!flags['email-key'], vault });
|
|
1403
|
+
return flags.json ? print.json(result) : print.info(result.link);
|
|
1404
|
+
}
|
|
1405
|
+
case 'revoke': {
|
|
1406
|
+
if (args.length !== 2) usageExit('coinpay document revoke <id> <recipient-id>');
|
|
1407
|
+
const result = await revokeDocumentRecipient(client, args[0], args[1]);
|
|
1408
|
+
return flags.json ? print.json(result) : print.success('Revoked; document is now ' + result.status);
|
|
1409
|
+
}
|
|
1410
|
+
case 'versions': {
|
|
1411
|
+
if (args.length !== 1) usageExit('coinpay document versions <id>');
|
|
1412
|
+
const rows = await listDocumentVersions(client, args[0]);
|
|
1413
|
+
if (flags.json) return print.json(rows);
|
|
1414
|
+
for (const v of rows) print.info('v' + v.version + ' ' + v.created_at + ' ' + v.filename + ' ' + v.bytes + ' bytes' + (v.uploaded_by ? ' ' + v.uploaded_by : '') + (v.note ? ' ' + v.note : ''));
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
case 'new-version': {
|
|
1418
|
+
if (args.length !== 2) usageExit('coinpay document new-version <id> <file> [--note "what changed"]');
|
|
1419
|
+
const { document } = await getDocument(client, args[0]);
|
|
1420
|
+
const vault = await documentsVault(client, document.business_id);
|
|
1421
|
+
const result = await uploadDocumentVersion(client, args[0], { vault, bytes: readFileSync(args[1]), filename: args[1], note: flags.note });
|
|
1422
|
+
return flags.json ? print.json(result) : print.success('Uploaded version ' + result.version + ' (encrypted)');
|
|
1423
|
+
}
|
|
1424
|
+
case 'download': {
|
|
1425
|
+
if (args.length !== 1) usageExit('coinpay document download <id> [--signed | --revision N] [--out path]');
|
|
1426
|
+
const { document } = await getDocument(client, args[0]);
|
|
1427
|
+
const vault = await documentsVault(client, document.business_id);
|
|
1428
|
+
const file = await downloadDocument(client, args[0], {
|
|
1429
|
+
version: flags.signed ? 'signed' : 'original',
|
|
1430
|
+
revision: flags.revision ? Number(flags.revision) : undefined,
|
|
1431
|
+
vault,
|
|
1432
|
+
});
|
|
1433
|
+
const out = flags.out || file.filename;
|
|
1434
|
+
writeFileSync(out, file.bytes, { mode: 0o600 });
|
|
1435
|
+
return print.success('Decrypted ' + file.bytes.length + ' bytes to ' + out + ' (sha256 verified)');
|
|
1436
|
+
}
|
|
1437
|
+
case 'void': {
|
|
1438
|
+
if (args.length !== 1) usageExit('coinpay document void <id> [--reason "..."]');
|
|
1439
|
+
await voidDocument(client, args[0], { reason: flags.reason });
|
|
1440
|
+
return print.success('Voided');
|
|
1441
|
+
}
|
|
1442
|
+
case 'delete': {
|
|
1443
|
+
if (args.length !== 1) usageExit('coinpay document delete <id>');
|
|
1444
|
+
await deleteDocument(client, args[0]);
|
|
1445
|
+
return print.success('Deleted');
|
|
1446
|
+
}
|
|
1447
|
+
default:
|
|
1448
|
+
usageExit('coinpay document <vault-setup|list|upload|share|get|versions|new-version|link|revoke|download|void|delete>');
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1280
1452
|
async function handleContractor(subcommand, args, flags) {
|
|
1281
1453
|
const client = createClient();
|
|
1282
1454
|
requireFlagValues(flags, ['business-id', 'name', 'email', 'country', 'title', 'kind', 'rate', 'frequency', 'start', 'end', 'note', 'signature', 'reason', 'amount', 'contract', 'description', 'status', 'year']);
|
|
@@ -5257,6 +5429,11 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
5257
5429
|
await handleProject(subcommand, args, flags);
|
|
5258
5430
|
break;
|
|
5259
5431
|
|
|
5432
|
+
case 'document':
|
|
5433
|
+
case 'documents':
|
|
5434
|
+
await handleDocument(subcommand, args, flags);
|
|
5435
|
+
break;
|
|
5436
|
+
|
|
5260
5437
|
case 'contractor':
|
|
5261
5438
|
case 'contractors':
|
|
5262
5439
|
await handleContractor(subcommand, args, flags);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "CoinPay SDK & CLI \u2014 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",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { CoinPayClient } from './client.js';
|
|
2
|
+
|
|
3
|
+
export interface CoinPayDocument {
|
|
4
|
+
id: string;
|
|
5
|
+
business_id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
message: string | null;
|
|
8
|
+
filename: string;
|
|
9
|
+
content_type: string;
|
|
10
|
+
bytes: number;
|
|
11
|
+
sha256: string;
|
|
12
|
+
wrapped_key: string;
|
|
13
|
+
vault_version: number;
|
|
14
|
+
current_version: number;
|
|
15
|
+
status: 'draft' | 'shared' | 'out_for_signature' | 'completed' | 'declined' | 'voided';
|
|
16
|
+
signed_sha256: string | null;
|
|
17
|
+
signed_bytes: number | null;
|
|
18
|
+
completed_at: string | null;
|
|
19
|
+
voided_at: string | null;
|
|
20
|
+
created_at: string;
|
|
21
|
+
signers?: number;
|
|
22
|
+
signed?: number;
|
|
23
|
+
viewers?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DocumentRecipient {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
email: string;
|
|
30
|
+
role: 'signer' | 'viewer' | 'editor';
|
|
31
|
+
status: 'pending' | 'viewed' | 'signed' | 'declined';
|
|
32
|
+
signed_at: string | null;
|
|
33
|
+
signature_name: string | null;
|
|
34
|
+
revoked_at: string | null;
|
|
35
|
+
/** The link without the key (server copy). */
|
|
36
|
+
url?: string;
|
|
37
|
+
/** The link with `#k=<key>`, when the vault was unlocked. Hand it over yourself. */
|
|
38
|
+
link?: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DocumentVersion {
|
|
42
|
+
version: number;
|
|
43
|
+
filename: string;
|
|
44
|
+
content_type: string;
|
|
45
|
+
bytes: number;
|
|
46
|
+
sha256: string;
|
|
47
|
+
note: string | null;
|
|
48
|
+
uploaded_by: string | null;
|
|
49
|
+
created_at: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DocumentVault {
|
|
53
|
+
version: number;
|
|
54
|
+
kdf_salt: string;
|
|
55
|
+
kdf_iterations: number;
|
|
56
|
+
wrapped_key: string;
|
|
57
|
+
created_at: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface UnlockedVault {
|
|
61
|
+
vaultKey: Uint8Array;
|
|
62
|
+
version: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function listDocuments(client: CoinPayClient, opts?: { businessId?: string; status?: string }): Promise<CoinPayDocument[]>;
|
|
66
|
+
export function getDocument(client: CoinPayClient, documentId: string): Promise<{ document: CoinPayDocument & { signed_available: boolean }; recipients: DocumentRecipient[]; events: unknown[]; versions: DocumentVersion[]; certificate: unknown }>;
|
|
67
|
+
export function listDocumentVaults(client: CoinPayClient, opts?: { businessId?: string }): Promise<DocumentVault[]>;
|
|
68
|
+
export function setupDocumentsVault(client: CoinPayClient, opts: { businessId: string; passphrase: string }): Promise<UnlockedVault>;
|
|
69
|
+
export function unlockDocumentsVault(client: CoinPayClient, opts: { businessId: string; passphrase: string }): Promise<UnlockedVault>;
|
|
70
|
+
export function uploadDocument(client: CoinPayClient, opts: { businessId: string; vault: UnlockedVault; bytes: Uint8Array; filename: string; title?: string; message?: string }): Promise<CoinPayDocument>;
|
|
71
|
+
export function shareDocument(
|
|
72
|
+
client: CoinPayClient,
|
|
73
|
+
documentId: string,
|
|
74
|
+
opts: { recipients: { name: string; email: string; role?: 'signer' | 'viewer' | 'editor'; expires_in_days?: number }[]; message?: string | null; sendEmail?: boolean; emailKey?: boolean; vault?: UnlockedVault }
|
|
75
|
+
): Promise<{ status: string; recipients: DocumentRecipient[] }>;
|
|
76
|
+
export function newDocumentLink(client: CoinPayClient, documentId: string, recipientId: string, opts?: { sendEmail?: boolean; emailKey?: boolean; vault?: UnlockedVault }): Promise<{ url: string; link: string | null }>;
|
|
77
|
+
export function revokeDocumentRecipient(client: CoinPayClient, documentId: string, recipientId: string): Promise<{ status: string }>;
|
|
78
|
+
export function voidDocument(client: CoinPayClient, documentId: string, opts?: { reason?: string }): Promise<unknown>;
|
|
79
|
+
export function deleteDocument(client: CoinPayClient, documentId: string): Promise<unknown>;
|
|
80
|
+
export function listDocumentVersions(client: CoinPayClient, documentId: string): Promise<DocumentVersion[]>;
|
|
81
|
+
export function uploadDocumentVersion(client: CoinPayClient, documentId: string, opts: { vault: UnlockedVault; bytes: Uint8Array; filename: string; note?: string }): Promise<{ version: number }>;
|
|
82
|
+
export function downloadDocument(
|
|
83
|
+
client: CoinPayClient,
|
|
84
|
+
documentId: string,
|
|
85
|
+
opts: { version?: 'original' | 'signed'; revision?: number; vault: UnlockedVault }
|
|
86
|
+
): Promise<{ bytes: Uint8Array; filename: string; document: CoinPayDocument; revision?: number }>;
|
package/src/documents.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Documents: upload, share by private link and collect e-signatures, end-to-end
|
|
3
|
+
* encrypted. CoinPay stores only ciphertext; every key is handled here, on
|
|
4
|
+
* your machine, exactly as the web app does it in the browser
|
|
5
|
+
* (src/lib/documents/e2e.ts in coinpayportal):
|
|
6
|
+
*
|
|
7
|
+
* - each document has its own random AES-256-GCM key (DK);
|
|
8
|
+
* - the business keeps DK wrapped under its vault key (VK);
|
|
9
|
+
* - VK is wrapped under a key derived from the documents passphrase (PBKDF2-SHA256);
|
|
10
|
+
* - a recipient link carries DK after `#k=`, which is never sent to the server.
|
|
11
|
+
*
|
|
12
|
+
* Sealed blob format: 0x01 || 12-byte IV || AES-GCM ciphertext+tag.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const VERSION = 0x01;
|
|
16
|
+
const IV_BYTES = 12;
|
|
17
|
+
export const PBKDF2_ITERATIONS = 600_000;
|
|
18
|
+
|
|
19
|
+
const subtle = () => globalThis.crypto.subtle;
|
|
20
|
+
const enc = (s) => new TextEncoder().encode(s);
|
|
21
|
+
const q = (params) => {
|
|
22
|
+
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '');
|
|
23
|
+
return entries.length ? '?' + new URLSearchParams(entries.map(([k, v]) => [k, String(v)])).toString() : '';
|
|
24
|
+
};
|
|
25
|
+
const json = (method, body) => ({ method, body: JSON.stringify(body) });
|
|
26
|
+
const id = (v) => encodeURIComponent(v);
|
|
27
|
+
|
|
28
|
+
export const DOCUMENT_TYPES = {
|
|
29
|
+
pdf: 'application/pdf',
|
|
30
|
+
png: 'image/png',
|
|
31
|
+
jpg: 'image/jpeg',
|
|
32
|
+
jpeg: 'image/jpeg',
|
|
33
|
+
txt: 'text/plain',
|
|
34
|
+
md: 'text/plain',
|
|
35
|
+
csv: 'text/csv',
|
|
36
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
37
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
38
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const AAD = {
|
|
42
|
+
original: (documentId) => `coinpay-doc:original:${documentId}`,
|
|
43
|
+
version: (documentId, n) => (n === 1 ? `coinpay-doc:original:${documentId}` : `coinpay-doc:original:${documentId}:v${n}`),
|
|
44
|
+
signed: (documentId) => `coinpay-doc:signed:${documentId}`,
|
|
45
|
+
documentKey: (businessId) => `coinpay-doc:dk:${businessId}`,
|
|
46
|
+
vaultKey: (businessId) => `coinpay-doc:vk:${businessId}`,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Crypto (byte-for-byte the same as the web app)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export function toB64url(bytes) {
|
|
54
|
+
return Buffer.from(bytes).toString('base64url');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function fromB64url(text) {
|
|
58
|
+
if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new Error('Not base64url');
|
|
59
|
+
return new Uint8Array(Buffer.from(text, 'base64url'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function sha256Hex(data) {
|
|
63
|
+
return Buffer.from(await subtle().digest('SHA-256', data)).toString('hex');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function aesKey(raw) {
|
|
67
|
+
if (raw.length !== 32) throw new Error('Expected a 256-bit key');
|
|
68
|
+
return subtle().importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function newKey() {
|
|
72
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function seal(rawKey, plain, aad) {
|
|
76
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
77
|
+
const ct = new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv, additionalData: enc(aad) }, await aesKey(rawKey), plain));
|
|
78
|
+
const out = new Uint8Array(1 + IV_BYTES + ct.length);
|
|
79
|
+
out[0] = VERSION;
|
|
80
|
+
out.set(iv, 1);
|
|
81
|
+
out.set(ct, 1 + IV_BYTES);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function open(rawKey, sealed, aad) {
|
|
86
|
+
if (sealed.length < 1 + IV_BYTES + 16 || sealed[0] !== VERSION) throw new Error('Not an encrypted document');
|
|
87
|
+
try {
|
|
88
|
+
return new Uint8Array(
|
|
89
|
+
await subtle().decrypt({ name: 'AES-GCM', iv: sealed.subarray(1, 1 + IV_BYTES), additionalData: enc(aad) }, await aesKey(rawKey), sealed.subarray(1 + IV_BYTES))
|
|
90
|
+
);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error('Wrong key, or the file was altered');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function passphraseKey(passphrase, salt, iterations = PBKDF2_ITERATIONS) {
|
|
97
|
+
const base = await subtle().importKey('raw', enc(passphrase.normalize('NFKC')), 'PBKDF2', false, ['deriveBits']);
|
|
98
|
+
return new Uint8Array(await subtle().deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, base, 256));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function wrapVault(businessId, vaultKey, passphrase) {
|
|
102
|
+
const salt = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
103
|
+
const kek = await passphraseKey(passphrase, salt);
|
|
104
|
+
return { kdf_salt: toB64url(salt), kdf_iterations: PBKDF2_ITERATIONS, wrapped_key: toB64url(await seal(kek, vaultKey, AAD.vaultKey(businessId))) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function unlockVaultRecord(businessId, record, passphrase) {
|
|
108
|
+
const kek = await passphraseKey(passphrase, fromB64url(record.kdf_salt), record.kdf_iterations);
|
|
109
|
+
try {
|
|
110
|
+
return await open(kek, fromB64url(record.wrapped_key), AAD.vaultKey(businessId));
|
|
111
|
+
} catch {
|
|
112
|
+
throw new Error('Wrong passphrase');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `<url>#k=<document key>`: the link a recipient needs to open the file. */
|
|
117
|
+
export function linkWithKey(url, documentKey) {
|
|
118
|
+
return `${url}#k=${toB64url(documentKey)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// API
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
export async function listDocuments(client, { businessId, status } = {}) {
|
|
126
|
+
const data = await client.request('/documents' + q({ business_id: businessId, status }));
|
|
127
|
+
return data.documents ?? [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The document (metadata only), who has it and the audit trail. */
|
|
131
|
+
export async function getDocument(client, documentId) {
|
|
132
|
+
return client.request('/documents/' + id(documentId));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function listDocumentVaults(client, { businessId } = {}) {
|
|
136
|
+
const data = await client.request('/documents/vault' + q({ business_id: businessId }));
|
|
137
|
+
return data.vaults ?? [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Create the business's documents vault (admin). Lose the passphrase and the documents are gone. */
|
|
141
|
+
export async function setupDocumentsVault(client, { businessId, passphrase }) {
|
|
142
|
+
if (!businessId) throw new Error('businessId is required');
|
|
143
|
+
if (!passphrase || passphrase.length < 10) throw new Error('Use a passphrase of at least 10 characters');
|
|
144
|
+
const existing = await listDocumentVaults(client, { businessId });
|
|
145
|
+
if (existing.length) throw new Error('This business already has a documents vault');
|
|
146
|
+
const vaultKey = newKey();
|
|
147
|
+
const data = await client.request('/documents/vault', json('POST', { business_id: businessId, ...(await wrapVault(businessId, vaultKey, passphrase)) }));
|
|
148
|
+
return { vaultKey, version: data.vault.version };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The vault key, from the passphrase. Tries every version, newest first. */
|
|
152
|
+
export async function unlockDocumentsVault(client, { businessId, passphrase }) {
|
|
153
|
+
if (!businessId) throw new Error('businessId is required');
|
|
154
|
+
const vaults = await listDocumentVaults(client, { businessId });
|
|
155
|
+
if (!vaults.length) throw new Error('This business has no documents vault yet; run `coinpay document vault-setup`');
|
|
156
|
+
for (const v of vaults) {
|
|
157
|
+
try {
|
|
158
|
+
return { vaultKey: await unlockVaultRecord(businessId, v, passphrase), version: v.version };
|
|
159
|
+
} catch {
|
|
160
|
+
// next version
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
throw new Error('Wrong passphrase');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function documentKey(client, doc, vault) {
|
|
167
|
+
return open(vault.vaultKey, fromB64url(doc.wrapped_key), AAD.documentKey(doc.business_id));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Encrypt and upload. `bytes` is the file content; `filename` decides its type. */
|
|
171
|
+
export async function uploadDocument(client, { businessId, vault, bytes, filename, title, message }) {
|
|
172
|
+
const ext = String(filename).toLowerCase().split('.').pop();
|
|
173
|
+
const contentType = DOCUMENT_TYPES[ext];
|
|
174
|
+
if (!contentType) throw new Error('Unsupported file type: use ' + Object.keys(DOCUMENT_TYPES).join(', '));
|
|
175
|
+
const plain = new Uint8Array(bytes);
|
|
176
|
+
const docId = globalThis.crypto.randomUUID();
|
|
177
|
+
const dk = newKey();
|
|
178
|
+
const meta = {
|
|
179
|
+
id: docId,
|
|
180
|
+
business_id: businessId,
|
|
181
|
+
title: title || String(filename).replace(/\.[A-Za-z0-9]{1,5}$/, ''),
|
|
182
|
+
message: message || null,
|
|
183
|
+
filename: String(filename).split(/[\\/]/).pop(),
|
|
184
|
+
content_type: contentType,
|
|
185
|
+
bytes: plain.length,
|
|
186
|
+
sha256: await sha256Hex(plain),
|
|
187
|
+
wrapped_key: toB64url(await seal(vault.vaultKey, dk, AAD.documentKey(businessId))),
|
|
188
|
+
vault_version: vault.version,
|
|
189
|
+
};
|
|
190
|
+
const form = new FormData();
|
|
191
|
+
form.set('meta', JSON.stringify(meta));
|
|
192
|
+
form.set('file', new Blob([await seal(dk, plain, AAD.original(docId))], { type: 'application/octet-stream' }), 'sealed.bin');
|
|
193
|
+
const data = await client.requestForm('/documents', form);
|
|
194
|
+
return data.document;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Share with signers and viewers. With `vault`, each returned link carries the
|
|
199
|
+
* key; hand it to the person yourself. `emailKey: true` also puts the key in
|
|
200
|
+
* the invite email (convenient, but the mail path can then read the document).
|
|
201
|
+
*/
|
|
202
|
+
export async function shareDocument(client, documentId, { recipients, message, sendEmail, emailKey, vault }) {
|
|
203
|
+
const doc = (await getDocument(client, documentId)).document;
|
|
204
|
+
const dk = vault ? await documentKey(client, doc, vault) : null;
|
|
205
|
+
if (emailKey && !dk) throw new Error('emailKey needs the vault unlocked');
|
|
206
|
+
const body = { recipients };
|
|
207
|
+
if (message !== undefined) body.message = message;
|
|
208
|
+
if (sendEmail === false) body.send_email = false;
|
|
209
|
+
if (emailKey) body.email_document_key = toB64url(dk);
|
|
210
|
+
const data = await client.request(`/documents/${id(documentId)}/recipients`, json('POST', body));
|
|
211
|
+
return { ...data, recipients: data.recipients.map((r) => ({ ...r, link: dk ? linkWithKey(r.url, dk) : null })) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A new link for one recipient; the old one stops working. */
|
|
215
|
+
export async function newDocumentLink(client, documentId, recipientId, { sendEmail, emailKey, vault } = {}) {
|
|
216
|
+
const doc = (await getDocument(client, documentId)).document;
|
|
217
|
+
const dk = vault ? await documentKey(client, doc, vault) : null;
|
|
218
|
+
const body = {};
|
|
219
|
+
if (sendEmail === false) body.send_email = false;
|
|
220
|
+
if (emailKey && dk) body.email_document_key = toB64url(dk);
|
|
221
|
+
const data = await client.request(`/documents/${id(documentId)}/recipients/${id(recipientId)}`, json('POST', body));
|
|
222
|
+
return { url: data.url, link: dk ? linkWithKey(data.url, dk) : null };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function revokeDocumentRecipient(client, documentId, recipientId) {
|
|
226
|
+
return client.request(`/documents/${id(documentId)}/recipients/${id(recipientId)}`, { method: 'DELETE' });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function voidDocument(client, documentId, { reason } = {}) {
|
|
230
|
+
return client.request('/documents/' + id(documentId), json('PATCH', { action: 'void', ...(reason ? { reason } : {}) }));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function deleteDocument(client, documentId) {
|
|
234
|
+
return client.request('/documents/' + id(documentId), { method: 'DELETE' });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Every version of the document, newest first (metadata; the files stay sealed). */
|
|
238
|
+
export async function listDocumentVersions(client, documentId) {
|
|
239
|
+
const data = await client.request(`/documents/${id(documentId)}/versions`);
|
|
240
|
+
return data.versions ?? [];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Encrypt and upload the next version. Refused once anyone has signed;
|
|
245
|
+
* earlier versions stay in the history.
|
|
246
|
+
*/
|
|
247
|
+
export async function uploadDocumentVersion(client, documentId, { vault, bytes, filename, note }) {
|
|
248
|
+
const doc = (await getDocument(client, documentId)).document;
|
|
249
|
+
const ext = String(filename).toLowerCase().split('.').pop();
|
|
250
|
+
const contentType = DOCUMENT_TYPES[ext];
|
|
251
|
+
if (!contentType) throw new Error('Unsupported file type: use ' + Object.keys(DOCUMENT_TYPES).join(', '));
|
|
252
|
+
const dk = await documentKey(client, doc, vault);
|
|
253
|
+
const plain = new Uint8Array(bytes);
|
|
254
|
+
const next = doc.current_version + 1;
|
|
255
|
+
const form = new FormData();
|
|
256
|
+
form.set(
|
|
257
|
+
'meta',
|
|
258
|
+
JSON.stringify({ version: next, filename: String(filename).split(/[\\/]/).pop(), content_type: contentType, bytes: plain.length, sha256: await sha256Hex(plain), note: note || null })
|
|
259
|
+
);
|
|
260
|
+
form.set('file', new Blob([await seal(dk, plain, AAD.version(documentId, next))], { type: 'application/octet-stream' }), 'sealed.bin');
|
|
261
|
+
return client.requestForm(`/documents/${id(documentId)}/versions`, form);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Download and decrypt the current file, an earlier `revision`, or the signed
|
|
266
|
+
* copy (`version: 'signed'`), checking its sha256.
|
|
267
|
+
*/
|
|
268
|
+
export async function downloadDocument(client, documentId, { version = 'original', revision, vault }) {
|
|
269
|
+
const data = await getDocument(client, documentId);
|
|
270
|
+
const doc = data.document;
|
|
271
|
+
const dk = await documentKey(client, doc, vault);
|
|
272
|
+
if (version === 'signed') {
|
|
273
|
+
const { bytes } = await client.requestBinary(`/documents/${id(documentId)}/file?version=signed`);
|
|
274
|
+
const plain = await open(dk, bytes, AAD.signed(documentId));
|
|
275
|
+
if (doc.signed_sha256 && (await sha256Hex(plain)) !== doc.signed_sha256) throw new Error('The file does not match its recorded sha256');
|
|
276
|
+
return { bytes: plain, filename: doc.filename.replace(/\.[A-Za-z0-9]{1,5}$/, '') + '-signed.pdf', document: doc };
|
|
277
|
+
}
|
|
278
|
+
const n = revision || doc.current_version;
|
|
279
|
+
const entry = (data.versions ?? []).find((v) => v.version === n) ?? (n === doc.current_version ? doc : null);
|
|
280
|
+
if (!entry) throw new Error('No such version');
|
|
281
|
+
const { bytes } = await client.requestBinary(`/documents/${id(documentId)}/file?n=${n}`);
|
|
282
|
+
const plain = await open(dk, bytes, AAD.version(documentId, n));
|
|
283
|
+
if ((await sha256Hex(plain)) !== entry.sha256) throw new Error('The file does not match its recorded sha256');
|
|
284
|
+
return { bytes: plain, filename: entry.filename, document: doc, revision: n };
|
|
285
|
+
}
|
package/src/finances-commands.js
CHANGED
|
@@ -891,10 +891,21 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
891
891
|
const action = flags.off ? 'off' : args[0] || 'show';
|
|
892
892
|
const describe = (s) => s
|
|
893
893
|
? `Daily summary ${s.active ? 'on' : 'off'} · ${String(s.hour).padStart(2, '0')}:00 ${s.timezone} · to ${s.recipients.join(', ')}${s.nextRunAt ? ` · next ${s.nextRunAt}` : ''}${s.lastSentAt ? ` · last ${s.lastSentAt}` : ''}`
|
|
894
|
-
: 'No daily summary configured. Turn it on with: coinpay finances daily on --hour 7
|
|
894
|
+
: 'No daily summary configured. Turn it on with: coinpay finances daily on --hour 7';
|
|
895
|
+
const targetText = (t) => (t === null || t === undefined ? 'no daily spend target' : `target $${t}/day`);
|
|
896
|
+
if (flags.target !== undefined) {
|
|
897
|
+
const raw = String(flags.target);
|
|
898
|
+
const target = ['off', 'clear', 'none'].includes(raw) ? null : Number(raw.replace(/^\$/, ''));
|
|
899
|
+
if (target !== null && (!Number.isFinite(target) || target < 0)) throw new CliExit(EXIT.INVALID, 'The target is a daily amount, e.g. --target 100, or --target clear');
|
|
900
|
+
const data = await api.setDailySpendTarget(client, target);
|
|
901
|
+
if (action === 'show') {
|
|
902
|
+
emit({ schedule: data.schedule, target: data.target }, `${describe(data.schedule)}\n${target === null ? 'Cleared the daily spend target.' : `Daily spend target set to $${data.target}/day.`}`);
|
|
903
|
+
return EXIT.OK;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
895
906
|
if (action === 'show') {
|
|
896
|
-
const s = await api.
|
|
897
|
-
emit({ schedule: s }, describe(s));
|
|
907
|
+
const { schedule: s, target } = await api.getDailySummaryWithTarget(client);
|
|
908
|
+
emit({ schedule: s, target }, `${describe(s)} · ${targetText(target)}`);
|
|
898
909
|
return EXIT.OK;
|
|
899
910
|
}
|
|
900
911
|
if (action === 'on' || action === 'set') {
|
|
@@ -917,7 +928,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
917
928
|
emit(data, `Sent to ${data.sent.join(', ') || 'nobody'} (${data.due} due, ${data.overdue} overdue)${data.failed.length ? `; failed: ${data.failed.map((f) => `${f.to} (${f.error})`).join(', ')}` : ''}`);
|
|
918
929
|
return data.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
|
|
919
930
|
}
|
|
920
|
-
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances daily [show|on --hour 7 --to a@x|off|send-now]');
|
|
931
|
+
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances daily [show|on --hour 7 --to a@x|off|send-now] [--target 100|--target clear]');
|
|
921
932
|
}
|
|
922
933
|
|
|
923
934
|
case 'payloads': {
|
package/src/finances-reports.js
CHANGED
|
@@ -461,6 +461,12 @@ export async function getDailySummary(client) {
|
|
|
461
461
|
return data.schedule;
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
+
/** The daily summary schedule plus the owner's $/day spend target (null when none). */
|
|
465
|
+
export async function getDailySummaryWithTarget(client) {
|
|
466
|
+
const data = await call(client, '/finances/email-schedules?kind=daily_summary');
|
|
467
|
+
return { schedule: data.schedule, target: data.dailySpendTarget ?? null };
|
|
468
|
+
}
|
|
469
|
+
|
|
464
470
|
export async function setDailySummary(client, { weekdays, hour, timezone, recipients, active } = {}) {
|
|
465
471
|
const data = await call(client, '/finances/email-schedules', {
|
|
466
472
|
method: 'POST',
|
|
@@ -469,6 +475,15 @@ export async function setDailySummary(client, { weekdays, hour, timezone, recipi
|
|
|
469
475
|
return data.schedule;
|
|
470
476
|
}
|
|
471
477
|
|
|
478
|
+
/** Set the daily summary's $/day spend target, or clear it with null. Does not turn the summary on. */
|
|
479
|
+
export async function setDailySpendTarget(client, target) {
|
|
480
|
+
const data = await call(client, '/finances/email-schedules', {
|
|
481
|
+
method: 'POST',
|
|
482
|
+
body: JSON.stringify({ kind: 'daily_summary', target }),
|
|
483
|
+
});
|
|
484
|
+
return { schedule: data.schedule, target: data.dailySpendTarget ?? null };
|
|
485
|
+
}
|
|
486
|
+
|
|
472
487
|
export async function deleteDailySummary(client) {
|
|
473
488
|
return call(client, '/finances/email-schedules?kind=daily_summary', { method: 'DELETE' });
|
|
474
489
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -114,6 +114,23 @@ export {
|
|
|
114
114
|
get1099Summary,
|
|
115
115
|
} from './contractors.js';
|
|
116
116
|
export type { Contractor, ContractorContract, ContractorMilestone, ContractorWorkItem, PayRun, PayRunItem, PayOutcome } from './contractors.js';
|
|
117
|
+
export {
|
|
118
|
+
listDocuments,
|
|
119
|
+
getDocument,
|
|
120
|
+
listDocumentVaults,
|
|
121
|
+
setupDocumentsVault,
|
|
122
|
+
unlockDocumentsVault,
|
|
123
|
+
uploadDocument,
|
|
124
|
+
shareDocument,
|
|
125
|
+
newDocumentLink,
|
|
126
|
+
revokeDocumentRecipient,
|
|
127
|
+
voidDocument,
|
|
128
|
+
deleteDocument,
|
|
129
|
+
downloadDocument,
|
|
130
|
+
listDocumentVersions,
|
|
131
|
+
uploadDocumentVersion,
|
|
132
|
+
} from './documents.js';
|
|
133
|
+
export type { CoinPayDocument, DocumentRecipient, DocumentVault, DocumentVersion, UnlockedVault } from './documents.js';
|
|
117
134
|
|
|
118
135
|
// Webhook exports
|
|
119
136
|
export {
|
package/src/index.js
CHANGED
|
@@ -128,6 +128,23 @@ import {
|
|
|
128
128
|
get1099Summary,
|
|
129
129
|
} from './contractors.js';
|
|
130
130
|
|
|
131
|
+
import {
|
|
132
|
+
listDocuments,
|
|
133
|
+
getDocument,
|
|
134
|
+
listDocumentVaults,
|
|
135
|
+
setupDocumentsVault,
|
|
136
|
+
unlockDocumentsVault,
|
|
137
|
+
uploadDocument,
|
|
138
|
+
shareDocument,
|
|
139
|
+
newDocumentLink,
|
|
140
|
+
revokeDocumentRecipient,
|
|
141
|
+
voidDocument,
|
|
142
|
+
deleteDocument,
|
|
143
|
+
downloadDocument,
|
|
144
|
+
listDocumentVersions,
|
|
145
|
+
uploadDocumentVersion,
|
|
146
|
+
} from './documents.js';
|
|
147
|
+
|
|
131
148
|
// Wallet exports
|
|
132
149
|
import {
|
|
133
150
|
WalletClient,
|
|
@@ -312,6 +329,20 @@ export {
|
|
|
312
329
|
payPayRun,
|
|
313
330
|
recordPayRunPayments,
|
|
314
331
|
get1099Summary,
|
|
332
|
+
listDocuments,
|
|
333
|
+
getDocument,
|
|
334
|
+
listDocumentVaults,
|
|
335
|
+
setupDocumentsVault,
|
|
336
|
+
unlockDocumentsVault,
|
|
337
|
+
uploadDocument,
|
|
338
|
+
shareDocument,
|
|
339
|
+
newDocumentLink,
|
|
340
|
+
revokeDocumentRecipient,
|
|
341
|
+
voidDocument,
|
|
342
|
+
deleteDocument,
|
|
343
|
+
downloadDocument,
|
|
344
|
+
listDocumentVersions,
|
|
345
|
+
uploadDocumentVersion,
|
|
315
346
|
|
|
316
347
|
// Wallet
|
|
317
348
|
WalletClient,
|
package/src/mcp.js
CHANGED
|
@@ -18,6 +18,7 @@ import * as sf from './statements-fetch.js';
|
|
|
18
18
|
import { listInvoices, getInvoice, createInvoice } from './invoices.js';
|
|
19
19
|
import * as projects from './projects.js';
|
|
20
20
|
import * as contractors from './contractors.js';
|
|
21
|
+
import * as documents from './documents.js';
|
|
21
22
|
|
|
22
23
|
export const PROTOCOL_VERSION = '2025-06-18';
|
|
23
24
|
|
|
@@ -95,13 +96,14 @@ export const TOOLS = [
|
|
|
95
96
|
},
|
|
96
97
|
{
|
|
97
98
|
name: 'finance_daily_summary',
|
|
98
|
-
description: 'The daily summary email (due soon, new activity, position): show it, turn it on (optionally hour 0-23 and recipients), turn it off,
|
|
99
|
+
description: 'The daily summary email (spend vs a $/day target, top cuts, due soon, new activity, position): show it, turn it on (optionally hour 0-23 and recipients), turn it off, send one now, or set_target (target in $/day, null clears).',
|
|
99
100
|
inputSchema: {
|
|
100
101
|
type: 'object',
|
|
101
102
|
properties: {
|
|
102
|
-
action: { type: 'string', enum: ['show', 'on', 'off', 'send_now'] },
|
|
103
|
+
action: { type: 'string', enum: ['show', 'on', 'off', 'send_now', 'set_target'] },
|
|
103
104
|
hour: { type: 'integer', minimum: 0, maximum: 23 },
|
|
104
105
|
to: { type: 'array', items: { type: 'string' }, maxItems: 5 },
|
|
106
|
+
target: { type: ['number', 'null'], minimum: 0 },
|
|
105
107
|
},
|
|
106
108
|
required: ['action'],
|
|
107
109
|
additionalProperties: false,
|
|
@@ -110,7 +112,8 @@ export const TOOLS = [
|
|
|
110
112
|
if (args.action === 'on') return text({ schedule: await reports.setDailySummary(client, { hour: args.hour, recipients: args.to, active: true }) });
|
|
111
113
|
if (args.action === 'off') return text(await reports.deleteDailySummary(client));
|
|
112
114
|
if (args.action === 'send_now') return text(await reports.sendDailySummaryNow(client));
|
|
113
|
-
return text(
|
|
115
|
+
if (args.action === 'set_target') return text(await reports.setDailySpendTarget(client, args.target ?? null));
|
|
116
|
+
return text(await reports.getDailySummaryWithTarget(client));
|
|
114
117
|
},
|
|
115
118
|
},
|
|
116
119
|
{
|
|
@@ -384,6 +387,31 @@ export const TOOLS = [
|
|
|
384
387
|
inputSchema: { type: 'object', properties: { businessId: { type: 'string' }, year: { type: 'integer' } }, additionalProperties: false },
|
|
385
388
|
handler: async (args, { client }) => text(await contractors.get1099Summary(client, args)),
|
|
386
389
|
},
|
|
390
|
+
// Documents are end-to-end encrypted. These tools see only metadata and the
|
|
391
|
+
// audit trail; opening, uploading or sharing a file needs the passphrase,
|
|
392
|
+
// which stays with a person (dashboard or `coinpay document`), never an agent.
|
|
393
|
+
{
|
|
394
|
+
name: 'document_list',
|
|
395
|
+
description: 'List documents with status (draft, shared, out_for_signature, completed, declined, voided) and signing progress. Contents are end-to-end encrypted and not available here.',
|
|
396
|
+
inputSchema: { type: 'object', properties: { businessId: { type: 'string' }, status: { type: 'string' } }, additionalProperties: false },
|
|
397
|
+
handler: async (args, { client }) => text({ documents: await documents.listDocuments(client, args) }),
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: 'document_get',
|
|
401
|
+
description: 'One document: who it was sent to, who signed (name, time, IP), and the full audit trail. No file contents or keys.',
|
|
402
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false },
|
|
403
|
+
handler: async (args, { client }) => {
|
|
404
|
+
const { document, recipients, events } = await documents.getDocument(client, args.id);
|
|
405
|
+
const { wrapped_key: _k, ...meta } = document;
|
|
406
|
+
return text({ document: meta, recipients, events });
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
name: 'document_void',
|
|
411
|
+
description: 'Void a document that has not been completed: links stop working and nobody can sign it. Cannot be undone.',
|
|
412
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string' }, reason: { type: 'string' } }, required: ['id'], additionalProperties: false },
|
|
413
|
+
handler: async (args, { client }) => text(await documents.voidDocument(client, args.id, { reason: args.reason })),
|
|
414
|
+
},
|
|
387
415
|
];
|
|
388
416
|
|
|
389
417
|
/** Answer one JSON-RPC message; null for a notification. */
|