@profullstack/coinpay 0.20.0 → 0.22.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 +27 -0
- package/bin/coinpay.js +228 -0
- package/package.json +1 -1
- package/src/documents-publish.d.ts +29 -0
- package/src/documents-publish.js +110 -0
- package/src/documents.d.ts +90 -0
- package/src/documents.js +318 -0
- package/src/index.d.ts +20 -0
- package/src/index.js +36 -0
- package/src/mcp.js +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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.22.0] - 2026-10-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `coinpay document publish <dir> --business-id <id>`: encrypt and publish a whole directory, keeping
|
|
12
|
+
its structure (under a folder named like the directory, or `--folder dest`, `--folder /` for the top
|
|
13
|
+
level). Publishing again syncs: new files upload, changed files become a new version, unchanged files
|
|
14
|
+
are skipped, signed documents are left alone, nothing remote is deleted. `--dry-run` shows the plan;
|
|
15
|
+
hidden files and `node_modules` are skipped unless `--include-hidden`.
|
|
16
|
+
- `coinpay document move <id> <folder>`; `upload --folder a/b`; `list` shows folders.
|
|
17
|
+
- Any file type can be uploaded (unknown types are opaque downloads; the server only holds ciphertext).
|
|
18
|
+
- SDK: `publishDirectory`, `planPublish`, `moveDocument`; `uploadDocument({ folder })`.
|
|
19
|
+
|
|
20
|
+
## [0.21.0] - 2026-10-19
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- Documents, end-to-end encrypted: `coinpay document vault-setup|list|upload|share|get|link|revoke|download|void|delete`.
|
|
24
|
+
Files are encrypted on your machine (AES-256-GCM, one key per document, wrapped under the
|
|
25
|
+
business vault key, which is wrapped under a PBKDF2 passphrase); CoinPay stores only ciphertext.
|
|
26
|
+
`share` adds signers and viewers and prints each full link with its `#k=` key; the invite
|
|
27
|
+
emails omit the key unless `--email-key`. `download` decrypts and verifies the sha256.
|
|
28
|
+
Invites can be signers, editors (upload new versions) or viewers (`--signers/--editors/--viewers`).
|
|
29
|
+
Every version is kept: `versions <id>`, `new-version <id> <file>`, `download --revision N`.
|
|
30
|
+
- SDK: `listDocuments`, `getDocument`, `setupDocumentsVault`, `unlockDocumentsVault`,
|
|
31
|
+
`uploadDocument`, `shareDocument`, `newDocumentLink`, `revokeDocumentRecipient`,
|
|
32
|
+
`voidDocument`, `deleteDocument`, `downloadDocument`, `listDocumentVersions`, `uploadDocumentVersion`.
|
|
33
|
+
- MCP: `document_list`, `document_get`, `document_void` (metadata only; no keys).
|
|
34
|
+
|
|
8
35
|
## [0.19.0] - 2026-10-17
|
|
9
36
|
|
|
10
37
|
### Added
|
package/bin/coinpay.js
CHANGED
|
@@ -66,6 +66,23 @@ 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
|
+
moveDocument,
|
|
84
|
+
} from '../src/documents.js';
|
|
85
|
+
import { publishDirectory } from '../src/documents-publish.js';
|
|
69
86
|
import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'fs';
|
|
70
87
|
import { execSync, spawn } from 'child_process';
|
|
71
88
|
import { createInterface } from 'readline';
|
|
@@ -534,6 +551,26 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
534
551
|
1099 Totals and who needs a 1099-NEC (--year 2026)
|
|
535
552
|
offboard <id> End contracts and revoke the portal link
|
|
536
553
|
|
|
554
|
+
${colors.bright}document${colors.reset} (end-to-end encrypted: CoinPay never sees the contents)
|
|
555
|
+
vault-setup Create the documents passphrase (--business-id, admins)
|
|
556
|
+
list Documents (--business-id) with signing progress
|
|
557
|
+
upload <file> Encrypt and upload: --business-id [--folder a/b] [--title] [--message]
|
|
558
|
+
publish <dir> Sync a whole directory, keeping its structure: --business-id
|
|
559
|
+
[--folder dest | --folder / for top level] [--dry-run] [--include-hidden]
|
|
560
|
+
new files upload, changed ones become versions, signed ones are left alone
|
|
561
|
+
move <id> <folder> File a document elsewhere (/ = top level)
|
|
562
|
+
share <id> --signers "Ada <ada@x.com>, bob@y.com" [--editors "..."] [--viewers "..."]
|
|
563
|
+
[--message] [--no-email] [--email-key puts the key in the email]
|
|
564
|
+
Prints each full link (with #k= key): send it privately
|
|
565
|
+
get <id> Status, people and audit trail
|
|
566
|
+
link <id> <recipient> New link for one person (old one stops working)
|
|
567
|
+
revoke <id> <recipient> Remove someone who has not signed
|
|
568
|
+
versions <id> The history: every uploaded version
|
|
569
|
+
new-version <id> <file> Encrypt and upload the next version [--note]
|
|
570
|
+
download <id> Decrypt to a file [--signed | --revision N] [--out path]
|
|
571
|
+
void <id> [--reason] delete <id>
|
|
572
|
+
Passphrase: COINPAY_DOCUMENTS_PASSPHRASE or a hidden prompt
|
|
573
|
+
|
|
537
574
|
${colors.bright}payrun${colors.reset}
|
|
538
575
|
list Pay runs
|
|
539
576
|
create Draft from fixed fees due + approved work [--date YYYY-MM-DD]
|
|
@@ -1277,6 +1314,192 @@ function usageExit(usage) {
|
|
|
1277
1314
|
process.exit(1);
|
|
1278
1315
|
}
|
|
1279
1316
|
|
|
1317
|
+
/** "Ada <ada@x.com>, bob@y.com" -> [{ name, email }] */
|
|
1318
|
+
function parsePeople(text, role) {
|
|
1319
|
+
return String(text || '')
|
|
1320
|
+
.split(',')
|
|
1321
|
+
.map((s) => s.trim())
|
|
1322
|
+
.filter(Boolean)
|
|
1323
|
+
.map((s) => {
|
|
1324
|
+
const m = /^(.*?)\s*<([^>]+)>$/.exec(s);
|
|
1325
|
+
const email = (m ? m[2] : s).trim();
|
|
1326
|
+
return { name: (m && m[1].trim()) || email, email, role };
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
async function documentsVault(client, businessId) {
|
|
1331
|
+
const passphrase = process.env.COINPAY_DOCUMENTS_PASSPHRASE || (await promptPassword('Documents passphrase: '));
|
|
1332
|
+
return unlockDocumentsVault(client, { businessId, passphrase });
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
async function handleDocument(subcommand, args, flags) {
|
|
1336
|
+
const client = createClient();
|
|
1337
|
+
requireFlagValues(flags, ['business-id', 'title', 'message', 'signers', 'viewers', 'editors', 'reason', 'out', 'note', 'revision', 'folder']);
|
|
1338
|
+
switch (subcommand) {
|
|
1339
|
+
case 'vault-setup': {
|
|
1340
|
+
if (!flags['business-id']) usageExit('coinpay document vault-setup --business-id <id>');
|
|
1341
|
+
print.warn('If this passphrase is lost, nobody (including CoinPay) can recover the documents.');
|
|
1342
|
+
const passphrase = process.env.COINPAY_DOCUMENTS_PASSPHRASE || (await promptPassword('New documents passphrase: '));
|
|
1343
|
+
if (!process.env.COINPAY_DOCUMENTS_PASSPHRASE && (await promptPassword('Repeat it: ')) !== passphrase) usageExit('The passphrases do not match');
|
|
1344
|
+
const vault = await setupDocumentsVault(client, { businessId: flags['business-id'], passphrase });
|
|
1345
|
+
return flags.json ? print.json({ version: vault.version }) : print.success('Documents vault created (version ' + vault.version + ')');
|
|
1346
|
+
}
|
|
1347
|
+
case 'list': {
|
|
1348
|
+
const rows = await listDocuments(client, { businessId: flags['business-id'] });
|
|
1349
|
+
if (flags.json) return print.json(rows);
|
|
1350
|
+
if (!rows.length) return print.info('No documents');
|
|
1351
|
+
for (const d of rows) {
|
|
1352
|
+
const progress = d.signers ? ' ' + d.signed + '/' + d.signers + ' signed' : '';
|
|
1353
|
+
print.info(d.id + ' ' + (d.folder ? d.folder + '/' : '') + d.title + ' ' + d.status + progress);
|
|
1354
|
+
}
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
case 'publish': {
|
|
1358
|
+
if (args.length !== 1 || !flags['business-id']) usageExit('coinpay document publish <dir> --business-id <id> [--folder path | --folder /] [--dry-run] [--include-hidden]');
|
|
1359
|
+
if (!existsSync(args[0])) usageExit('No such directory: ' + args[0]);
|
|
1360
|
+
const businessId = flags['business-id'];
|
|
1361
|
+
const dryRun = !!flags['dry-run'];
|
|
1362
|
+
const vault = dryRun ? undefined : await documentsVault(client, businessId);
|
|
1363
|
+
const labels = { upload: 'new', version: 'new version', unchanged: 'unchanged', locked: 'signed, left alone', too_large: 'too large (9 MB max)', empty: 'empty, skipped' };
|
|
1364
|
+
const result = await publishDirectory(client, {
|
|
1365
|
+
businessId,
|
|
1366
|
+
vault,
|
|
1367
|
+
dir: args[0],
|
|
1368
|
+
folder: flags.folder,
|
|
1369
|
+
includeHidden: !!flags['include-hidden'],
|
|
1370
|
+
dryRun,
|
|
1371
|
+
onFile: (p) => {
|
|
1372
|
+
if (flags.json) return;
|
|
1373
|
+
const where = (p.folder ? p.folder + '/' : '') + p.filename;
|
|
1374
|
+
if (p.result === 'ok') print.success((p.action === 'upload' ? 'uploaded ' : 'v' + p.version + ' ') + where);
|
|
1375
|
+
else print.error('failed ' + where + ': ' + p.error);
|
|
1376
|
+
},
|
|
1377
|
+
});
|
|
1378
|
+
if (flags.json) return print.json(result);
|
|
1379
|
+
const count = (a) => result.plan.filter((p) => p.action === a).length;
|
|
1380
|
+
if (dryRun) {
|
|
1381
|
+
for (const p of result.plan) print.info(labels[p.action].padEnd(20) + ' ' + (p.folder ? p.folder + '/' : '') + p.filename);
|
|
1382
|
+
print.info('Dry run: nothing was uploaded.');
|
|
1383
|
+
}
|
|
1384
|
+
const failed = result.plan.filter((p) => p.result === 'failed').length;
|
|
1385
|
+
print.info(
|
|
1386
|
+
'Into ' + (result.folder ? result.folder + '/' : 'the top level') + ': ' +
|
|
1387
|
+
count('upload') + ' new, ' + count('version') + ' new versions, ' + count('unchanged') + ' unchanged' +
|
|
1388
|
+
(count('locked') ? ', ' + count('locked') + ' signed (left alone)' : '') +
|
|
1389
|
+
(count('too_large') ? ', ' + count('too_large') + ' too large' : '') +
|
|
1390
|
+
(result.skippedHidden ? ', ' + result.skippedHidden + ' hidden skipped' : '') +
|
|
1391
|
+
(failed ? ', ' + failed + ' FAILED' : '')
|
|
1392
|
+
);
|
|
1393
|
+
if (failed) process.exitCode = 1;
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
case 'move': {
|
|
1397
|
+
if (args.length !== 2) usageExit('coinpay document move <id> <folder> (use / for the top level)');
|
|
1398
|
+
const result = await moveDocument(client, args[0], args[1]);
|
|
1399
|
+
return flags.json ? print.json(result) : print.success('Moved to ' + (result.folder || 'the top level'));
|
|
1400
|
+
}
|
|
1401
|
+
case 'upload': {
|
|
1402
|
+
if (args.length !== 1 || !flags['business-id']) usageExit('coinpay document upload <file> --business-id <id> [--folder path] [--title "..."] [--message "..."]');
|
|
1403
|
+
const vault = await documentsVault(client, flags['business-id']);
|
|
1404
|
+
const doc = await uploadDocument(client, {
|
|
1405
|
+
businessId: flags['business-id'],
|
|
1406
|
+
vault,
|
|
1407
|
+
bytes: readFileSync(args[0]),
|
|
1408
|
+
filename: args[0],
|
|
1409
|
+
title: flags.title,
|
|
1410
|
+
message: flags.message,
|
|
1411
|
+
folder: flags.folder,
|
|
1412
|
+
});
|
|
1413
|
+
if (flags.json) return print.json(doc);
|
|
1414
|
+
print.success('Uploaded (encrypted): ' + doc.id);
|
|
1415
|
+
return print.info('Send it with: coinpay document share ' + doc.id + ' --signers "Name <email>"');
|
|
1416
|
+
}
|
|
1417
|
+
case 'share': {
|
|
1418
|
+
const recipients = [...parsePeople(flags.signers, 'signer'), ...parsePeople(flags.editors, 'editor'), ...parsePeople(flags.viewers, 'viewer')];
|
|
1419
|
+
if (args.length !== 1 || !recipients.length) usageExit('coinpay document share <id> --signers "Ada <ada@x.com>" [--editors "..."] [--viewers "..."] [--message] [--no-email] [--email-key]');
|
|
1420
|
+
const { document } = await getDocument(client, args[0]);
|
|
1421
|
+
const vault = await documentsVault(client, document.business_id);
|
|
1422
|
+
const result = await shareDocument(client, args[0], {
|
|
1423
|
+
recipients,
|
|
1424
|
+
message: flags.message,
|
|
1425
|
+
sendEmail: !flags['no-email'],
|
|
1426
|
+
emailKey: !!flags['email-key'],
|
|
1427
|
+
vault,
|
|
1428
|
+
});
|
|
1429
|
+
if (flags.json) return print.json(result);
|
|
1430
|
+
print.success('Shared; document is now ' + result.status);
|
|
1431
|
+
if (!flags['email-key']) print.warn('The emails do NOT contain the key. Send each person their full link below on another channel:');
|
|
1432
|
+
for (const r of result.recipients) print.info(r.role.padEnd(6) + ' ' + r.email + ' ' + r.link);
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
case 'get': {
|
|
1436
|
+
if (args.length !== 1) usageExit('coinpay document get <id>');
|
|
1437
|
+
const data = await getDocument(client, args[0]);
|
|
1438
|
+
if (flags.json) return print.json(data);
|
|
1439
|
+
const d = data.document;
|
|
1440
|
+
print.info(d.title + ' ' + d.status + ' ' + d.filename + ' (' + d.bytes + ' bytes)');
|
|
1441
|
+
print.info('sha256 ' + d.sha256 + (d.signed_sha256 ? '\nsigned sha256 ' + d.signed_sha256 : ''));
|
|
1442
|
+
for (const r of data.recipients) {
|
|
1443
|
+
if (r.revoked_at) continue;
|
|
1444
|
+
print.info(' ' + r.id + ' ' + r.role + ' ' + r.name + ' <' + r.email + '> ' + r.status + (r.signed_at ? ' ' + r.signed_at : ''));
|
|
1445
|
+
}
|
|
1446
|
+
for (const e of data.events) print.info(' ' + e.created_at + ' ' + e.event + (e.actor_label ? ' ' + e.actor_label : '') + (e.ip ? ' ' + e.ip : ''));
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
case 'link': {
|
|
1450
|
+
if (args.length !== 2) usageExit('coinpay document link <id> <recipient-id> [--no-email] [--email-key]');
|
|
1451
|
+
const { document } = await getDocument(client, args[0]);
|
|
1452
|
+
const vault = await documentsVault(client, document.business_id);
|
|
1453
|
+
const result = await newDocumentLink(client, args[0], args[1], { sendEmail: !flags['no-email'], emailKey: !!flags['email-key'], vault });
|
|
1454
|
+
return flags.json ? print.json(result) : print.info(result.link);
|
|
1455
|
+
}
|
|
1456
|
+
case 'revoke': {
|
|
1457
|
+
if (args.length !== 2) usageExit('coinpay document revoke <id> <recipient-id>');
|
|
1458
|
+
const result = await revokeDocumentRecipient(client, args[0], args[1]);
|
|
1459
|
+
return flags.json ? print.json(result) : print.success('Revoked; document is now ' + result.status);
|
|
1460
|
+
}
|
|
1461
|
+
case 'versions': {
|
|
1462
|
+
if (args.length !== 1) usageExit('coinpay document versions <id>');
|
|
1463
|
+
const rows = await listDocumentVersions(client, args[0]);
|
|
1464
|
+
if (flags.json) return print.json(rows);
|
|
1465
|
+
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 : ''));
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
case 'new-version': {
|
|
1469
|
+
if (args.length !== 2) usageExit('coinpay document new-version <id> <file> [--note "what changed"]');
|
|
1470
|
+
const { document } = await getDocument(client, args[0]);
|
|
1471
|
+
const vault = await documentsVault(client, document.business_id);
|
|
1472
|
+
const result = await uploadDocumentVersion(client, args[0], { vault, bytes: readFileSync(args[1]), filename: args[1], note: flags.note });
|
|
1473
|
+
return flags.json ? print.json(result) : print.success('Uploaded version ' + result.version + ' (encrypted)');
|
|
1474
|
+
}
|
|
1475
|
+
case 'download': {
|
|
1476
|
+
if (args.length !== 1) usageExit('coinpay document download <id> [--signed | --revision N] [--out path]');
|
|
1477
|
+
const { document } = await getDocument(client, args[0]);
|
|
1478
|
+
const vault = await documentsVault(client, document.business_id);
|
|
1479
|
+
const file = await downloadDocument(client, args[0], {
|
|
1480
|
+
version: flags.signed ? 'signed' : 'original',
|
|
1481
|
+
revision: flags.revision ? Number(flags.revision) : undefined,
|
|
1482
|
+
vault,
|
|
1483
|
+
});
|
|
1484
|
+
const out = flags.out || file.filename;
|
|
1485
|
+
writeFileSync(out, file.bytes, { mode: 0o600 });
|
|
1486
|
+
return print.success('Decrypted ' + file.bytes.length + ' bytes to ' + out + ' (sha256 verified)');
|
|
1487
|
+
}
|
|
1488
|
+
case 'void': {
|
|
1489
|
+
if (args.length !== 1) usageExit('coinpay document void <id> [--reason "..."]');
|
|
1490
|
+
await voidDocument(client, args[0], { reason: flags.reason });
|
|
1491
|
+
return print.success('Voided');
|
|
1492
|
+
}
|
|
1493
|
+
case 'delete': {
|
|
1494
|
+
if (args.length !== 1) usageExit('coinpay document delete <id>');
|
|
1495
|
+
await deleteDocument(client, args[0]);
|
|
1496
|
+
return print.success('Deleted');
|
|
1497
|
+
}
|
|
1498
|
+
default:
|
|
1499
|
+
usageExit('coinpay document <vault-setup|list|upload|publish|move|share|get|versions|new-version|link|revoke|download|void|delete>');
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1280
1503
|
async function handleContractor(subcommand, args, flags) {
|
|
1281
1504
|
const client = createClient();
|
|
1282
1505
|
requireFlagValues(flags, ['business-id', 'name', 'email', 'country', 'title', 'kind', 'rate', 'frequency', 'start', 'end', 'note', 'signature', 'reason', 'amount', 'contract', 'description', 'status', 'year']);
|
|
@@ -5257,6 +5480,11 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
5257
5480
|
await handleProject(subcommand, args, flags);
|
|
5258
5481
|
break;
|
|
5259
5482
|
|
|
5483
|
+
case 'document':
|
|
5484
|
+
case 'documents':
|
|
5485
|
+
await handleDocument(subcommand, args, flags);
|
|
5486
|
+
break;
|
|
5487
|
+
|
|
5260
5488
|
case 'contractor':
|
|
5261
5489
|
case 'contractors':
|
|
5262
5490
|
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.22.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,29 @@
|
|
|
1
|
+
import type { CoinPayClient } from './client.js';
|
|
2
|
+
import type { CoinPayDocument, UnlockedVault } from './documents.js';
|
|
3
|
+
|
|
4
|
+
export interface PublishEntry {
|
|
5
|
+
abs: string;
|
|
6
|
+
rel: string;
|
|
7
|
+
size: number;
|
|
8
|
+
filename: string;
|
|
9
|
+
folder: string | null;
|
|
10
|
+
sha256?: string;
|
|
11
|
+
action: 'upload' | 'version' | 'unchanged' | 'locked' | 'too_large' | 'empty';
|
|
12
|
+
documentId?: string;
|
|
13
|
+
version?: number;
|
|
14
|
+
result?: 'ok' | 'failed';
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PublishResult {
|
|
19
|
+
plan: PublishEntry[];
|
|
20
|
+
skippedHidden: number;
|
|
21
|
+
folder: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function walkDirectory(dir: string, opts?: { includeHidden?: boolean }): Promise<{ files: { abs: string; rel: string; size: number }[]; skipped: number }>;
|
|
25
|
+
export function planPublish(local: Omit<PublishEntry, 'action'>[], remote: Pick<CoinPayDocument, 'id' | 'folder' | 'filename' | 'sha256' | 'status' | 'signed'>[]): PublishEntry[];
|
|
26
|
+
export function publishDirectory(
|
|
27
|
+
client: CoinPayClient,
|
|
28
|
+
opts: { businessId: string; vault?: UnlockedVault; dir: string; folder?: string; includeHidden?: boolean; dryRun?: boolean; concurrency?: number; onFile?: (entry: PublishEntry) => void }
|
|
29
|
+
): Promise<PublishResult>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish a local directory to a business's documents: every file is
|
|
3
|
+
* encrypted on this machine (see documents.js) and filed under the same
|
|
4
|
+
* folder structure. Publishing again syncs: new files are uploaded, changed
|
|
5
|
+
* files become a new version, unchanged ones are skipped, and documents that
|
|
6
|
+
* are already signed are never touched. Nothing remote is ever deleted.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
10
|
+
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
isSkippedPath,
|
|
13
|
+
listDocuments,
|
|
14
|
+
MAX_DOCUMENT_BYTES,
|
|
15
|
+
normalizeFolder,
|
|
16
|
+
sha256Hex,
|
|
17
|
+
uploadDocument,
|
|
18
|
+
uploadDocumentVersion,
|
|
19
|
+
} from './documents.js';
|
|
20
|
+
|
|
21
|
+
/** Every file under `dir`, with its path relative to `dir` ('/'-separated). Symlinks are not followed. */
|
|
22
|
+
export async function walkDirectory(dir, { includeHidden = false } = {}) {
|
|
23
|
+
const root = resolve(dir);
|
|
24
|
+
const out = [];
|
|
25
|
+
let skipped = 0;
|
|
26
|
+
const visit = async (abs) => {
|
|
27
|
+
for (const entry of await readdir(abs, { withFileTypes: true })) {
|
|
28
|
+
const child = join(abs, entry.name);
|
|
29
|
+
const rel = relative(root, child).split(sep).join('/');
|
|
30
|
+
if (!includeHidden && isSkippedPath(rel)) {
|
|
31
|
+
skipped++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (entry.isDirectory()) await visit(child);
|
|
35
|
+
else if (entry.isFile()) out.push({ abs: child, rel, size: (await stat(child)).size });
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
await visit(root);
|
|
39
|
+
out.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
40
|
+
return { files: out, skipped };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const key = (folder, filename) => `${folder ?? ''}\u0000${filename}`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decide what to do with each local file. `remote` is listDocuments() output
|
|
47
|
+
* (newest first, so the first match for a path wins). Pure: no I/O.
|
|
48
|
+
*
|
|
49
|
+
* Actions: upload | version | unchanged | locked (signed, completed, declined
|
|
50
|
+
* or voided remotely) | too_large | empty.
|
|
51
|
+
*/
|
|
52
|
+
export function planPublish(local, remote) {
|
|
53
|
+
const byPath = new Map();
|
|
54
|
+
for (const d of remote) if (!byPath.has(key(d.folder, d.filename))) byPath.set(key(d.folder, d.filename), d);
|
|
55
|
+
return local.map((f) => {
|
|
56
|
+
if (f.size === 0) return { ...f, action: 'empty' };
|
|
57
|
+
if (f.size > MAX_DOCUMENT_BYTES) return { ...f, action: 'too_large' };
|
|
58
|
+
const existing = byPath.get(key(f.folder, f.filename));
|
|
59
|
+
if (!existing) return { ...f, action: 'upload' };
|
|
60
|
+
if (existing.sha256 === f.sha256) return { ...f, action: 'unchanged', documentId: existing.id };
|
|
61
|
+
const locked = ['completed', 'declined', 'voided'].includes(existing.status) || (existing.signed ?? 0) > 0;
|
|
62
|
+
return { ...f, action: locked ? 'locked' : 'version', documentId: existing.id, status: existing.status };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Publish `dir`. `folder` is where its contents go: by default a folder named
|
|
68
|
+
* like the directory (as a desktop copy would); pass '' or '/' for the top level.
|
|
69
|
+
*/
|
|
70
|
+
export async function publishDirectory(client, { businessId, vault, dir, folder, includeHidden = false, dryRun = false, concurrency = 3, onFile } = {}) {
|
|
71
|
+
if (!businessId) throw new Error('businessId is required');
|
|
72
|
+
const prefix = normalizeFolder(folder === undefined ? basename(resolve(dir)) : folder);
|
|
73
|
+
const { files, skipped } = await walkDirectory(dir, { includeHidden });
|
|
74
|
+
|
|
75
|
+
const local = [];
|
|
76
|
+
for (const f of files) {
|
|
77
|
+
const parts = f.rel.split('/');
|
|
78
|
+
const filename = parts.pop();
|
|
79
|
+
const entry = { ...f, filename, folder: normalizeFolder([prefix ?? '', ...parts].join('/')) };
|
|
80
|
+
if (f.size > 0 && f.size <= MAX_DOCUMENT_BYTES) entry.sha256 = await sha256Hex(await readFile(f.abs));
|
|
81
|
+
local.push(entry);
|
|
82
|
+
}
|
|
83
|
+
const plan = planPublish(local, await listDocuments(client, { businessId }));
|
|
84
|
+
if (dryRun) return { plan, skippedHidden: skipped, folder: prefix };
|
|
85
|
+
|
|
86
|
+
let next = 0;
|
|
87
|
+
const work = plan.filter((p) => p.action === 'upload' || p.action === 'version');
|
|
88
|
+
const worker = async () => {
|
|
89
|
+
while (next < work.length) {
|
|
90
|
+
const p = work[next++];
|
|
91
|
+
try {
|
|
92
|
+
const bytes = await readFile(p.abs);
|
|
93
|
+
if (p.action === 'upload') {
|
|
94
|
+
const doc = await uploadDocument(client, { businessId, vault, bytes, filename: p.filename, folder: p.folder });
|
|
95
|
+
p.documentId = doc.id;
|
|
96
|
+
} else {
|
|
97
|
+
const result = await uploadDocumentVersion(client, p.documentId, { vault, bytes, filename: p.filename, note: 'published from ' + p.rel });
|
|
98
|
+
p.version = result.version;
|
|
99
|
+
}
|
|
100
|
+
p.result = 'ok';
|
|
101
|
+
} catch (err) {
|
|
102
|
+
p.result = 'failed';
|
|
103
|
+
p.error = err.message;
|
|
104
|
+
}
|
|
105
|
+
onFile?.(p);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, work.length) }, worker));
|
|
109
|
+
return { plan, skippedHidden: skipped, folder: prefix };
|
|
110
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
folder: string | null;
|
|
9
|
+
filename: string;
|
|
10
|
+
content_type: string;
|
|
11
|
+
bytes: number;
|
|
12
|
+
sha256: string;
|
|
13
|
+
wrapped_key: string;
|
|
14
|
+
vault_version: number;
|
|
15
|
+
current_version: number;
|
|
16
|
+
status: 'draft' | 'shared' | 'out_for_signature' | 'completed' | 'declined' | 'voided';
|
|
17
|
+
signed_sha256: string | null;
|
|
18
|
+
signed_bytes: number | null;
|
|
19
|
+
completed_at: string | null;
|
|
20
|
+
voided_at: string | null;
|
|
21
|
+
created_at: string;
|
|
22
|
+
signers?: number;
|
|
23
|
+
signed?: number;
|
|
24
|
+
viewers?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DocumentRecipient {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
email: string;
|
|
31
|
+
role: 'signer' | 'viewer' | 'editor';
|
|
32
|
+
status: 'pending' | 'viewed' | 'signed' | 'declined';
|
|
33
|
+
signed_at: string | null;
|
|
34
|
+
signature_name: string | null;
|
|
35
|
+
revoked_at: string | null;
|
|
36
|
+
/** The link without the key (server copy). */
|
|
37
|
+
url?: string;
|
|
38
|
+
/** The link with `#k=<key>`, when the vault was unlocked. Hand it over yourself. */
|
|
39
|
+
link?: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DocumentVersion {
|
|
43
|
+
version: number;
|
|
44
|
+
filename: string;
|
|
45
|
+
content_type: string;
|
|
46
|
+
bytes: number;
|
|
47
|
+
sha256: string;
|
|
48
|
+
note: string | null;
|
|
49
|
+
uploaded_by: string | null;
|
|
50
|
+
created_at: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DocumentVault {
|
|
54
|
+
version: number;
|
|
55
|
+
kdf_salt: string;
|
|
56
|
+
kdf_iterations: number;
|
|
57
|
+
wrapped_key: string;
|
|
58
|
+
created_at: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface UnlockedVault {
|
|
62
|
+
vaultKey: Uint8Array;
|
|
63
|
+
version: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function listDocuments(client: CoinPayClient, opts?: { businessId?: string; status?: string; folder?: string }): Promise<CoinPayDocument[]>;
|
|
67
|
+
export function moveDocument(client: CoinPayClient, documentId: string, folder: string | null): Promise<{ folder: string | null }>;
|
|
68
|
+
export function normalizeFolder(input: string | null | undefined): string | null;
|
|
69
|
+
export function contentTypeFor(filename: string): string;
|
|
70
|
+
export function getDocument(client: CoinPayClient, documentId: string): Promise<{ document: CoinPayDocument & { signed_available: boolean }; recipients: DocumentRecipient[]; events: unknown[]; versions: DocumentVersion[]; certificate: unknown }>;
|
|
71
|
+
export function listDocumentVaults(client: CoinPayClient, opts?: { businessId?: string }): Promise<DocumentVault[]>;
|
|
72
|
+
export function setupDocumentsVault(client: CoinPayClient, opts: { businessId: string; passphrase: string }): Promise<UnlockedVault>;
|
|
73
|
+
export function unlockDocumentsVault(client: CoinPayClient, opts: { businessId: string; passphrase: string }): Promise<UnlockedVault>;
|
|
74
|
+
export function uploadDocument(client: CoinPayClient, opts: { businessId: string; vault: UnlockedVault; bytes: Uint8Array; filename: string; title?: string; message?: string; folder?: string | null }): Promise<CoinPayDocument>;
|
|
75
|
+
export function shareDocument(
|
|
76
|
+
client: CoinPayClient,
|
|
77
|
+
documentId: string,
|
|
78
|
+
opts: { recipients: { name: string; email: string; role?: 'signer' | 'viewer' | 'editor'; expires_in_days?: number }[]; message?: string | null; sendEmail?: boolean; emailKey?: boolean; vault?: UnlockedVault }
|
|
79
|
+
): Promise<{ status: string; recipients: DocumentRecipient[] }>;
|
|
80
|
+
export function newDocumentLink(client: CoinPayClient, documentId: string, recipientId: string, opts?: { sendEmail?: boolean; emailKey?: boolean; vault?: UnlockedVault }): Promise<{ url: string; link: string | null }>;
|
|
81
|
+
export function revokeDocumentRecipient(client: CoinPayClient, documentId: string, recipientId: string): Promise<{ status: string }>;
|
|
82
|
+
export function voidDocument(client: CoinPayClient, documentId: string, opts?: { reason?: string }): Promise<unknown>;
|
|
83
|
+
export function deleteDocument(client: CoinPayClient, documentId: string): Promise<unknown>;
|
|
84
|
+
export function listDocumentVersions(client: CoinPayClient, documentId: string): Promise<DocumentVersion[]>;
|
|
85
|
+
export function uploadDocumentVersion(client: CoinPayClient, documentId: string, opts: { vault: UnlockedVault; bytes: Uint8Array; filename: string; note?: string }): Promise<{ version: number }>;
|
|
86
|
+
export function downloadDocument(
|
|
87
|
+
client: CoinPayClient,
|
|
88
|
+
documentId: string,
|
|
89
|
+
opts: { version?: 'original' | 'signed'; revision?: number; vault: UnlockedVault }
|
|
90
|
+
): Promise<{ bytes: Uint8Array; filename: string; document: CoinPayDocument; revision?: number }>;
|
package/src/documents.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
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 GENERIC_CONTENT_TYPE = 'application/octet-stream';
|
|
42
|
+
export const MAX_DOCUMENT_BYTES = 9 * 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
/** Known types preview in the browser; anything else is an opaque download (it is ciphertext to the server). */
|
|
45
|
+
export function contentTypeFor(filename) {
|
|
46
|
+
const name = String(filename);
|
|
47
|
+
const ext = name.includes('.') ? name.toLowerCase().split('.').pop() : '';
|
|
48
|
+
return DOCUMENT_TYPES[ext] ?? GENERIC_CONTENT_TYPE;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Same rules as the web app (src/lib/documents/types.ts): '/'-joined, no '.', '..' refused, null = top level. */
|
|
52
|
+
export function normalizeFolder(input) {
|
|
53
|
+
if (input == null) return null;
|
|
54
|
+
const segments = String(input)
|
|
55
|
+
.split(/[\\/]+/)
|
|
56
|
+
// eslint-disable-next-line no-control-regex
|
|
57
|
+
.map((s) => s.replace(/[\u0000-\u001f\u007f]/g, '').trim())
|
|
58
|
+
.filter((s) => s && s !== '.');
|
|
59
|
+
if (segments.some((s) => s === '..')) throw new Error('Folder paths cannot contain ".."');
|
|
60
|
+
if (segments.length > 20) throw new Error('Folders can be at most 20 levels deep');
|
|
61
|
+
const joined = segments.map((s) => s.slice(0, 100)).join('/');
|
|
62
|
+
if (joined.length > 1000) throw new Error('Folder path is too long');
|
|
63
|
+
return joined || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isSkippedPath(relativePath) {
|
|
67
|
+
return String(relativePath)
|
|
68
|
+
.split(/[\\/]+/)
|
|
69
|
+
.some((s) => (s.startsWith('.') && s !== '.' && s !== '..') || s === 'node_modules' || s === 'Thumbs.db' || s === 'desktop.ini');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const AAD = {
|
|
73
|
+
original: (documentId) => `coinpay-doc:original:${documentId}`,
|
|
74
|
+
version: (documentId, n) => (n === 1 ? `coinpay-doc:original:${documentId}` : `coinpay-doc:original:${documentId}:v${n}`),
|
|
75
|
+
signed: (documentId) => `coinpay-doc:signed:${documentId}`,
|
|
76
|
+
documentKey: (businessId) => `coinpay-doc:dk:${businessId}`,
|
|
77
|
+
vaultKey: (businessId) => `coinpay-doc:vk:${businessId}`,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Crypto (byte-for-byte the same as the web app)
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
export function toB64url(bytes) {
|
|
85
|
+
return Buffer.from(bytes).toString('base64url');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function fromB64url(text) {
|
|
89
|
+
if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new Error('Not base64url');
|
|
90
|
+
return new Uint8Array(Buffer.from(text, 'base64url'));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function sha256Hex(data) {
|
|
94
|
+
return Buffer.from(await subtle().digest('SHA-256', data)).toString('hex');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function aesKey(raw) {
|
|
98
|
+
if (raw.length !== 32) throw new Error('Expected a 256-bit key');
|
|
99
|
+
return subtle().importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function newKey() {
|
|
103
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function seal(rawKey, plain, aad) {
|
|
107
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
108
|
+
const ct = new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv, additionalData: enc(aad) }, await aesKey(rawKey), plain));
|
|
109
|
+
const out = new Uint8Array(1 + IV_BYTES + ct.length);
|
|
110
|
+
out[0] = VERSION;
|
|
111
|
+
out.set(iv, 1);
|
|
112
|
+
out.set(ct, 1 + IV_BYTES);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function open(rawKey, sealed, aad) {
|
|
117
|
+
if (sealed.length < 1 + IV_BYTES + 16 || sealed[0] !== VERSION) throw new Error('Not an encrypted document');
|
|
118
|
+
try {
|
|
119
|
+
return new Uint8Array(
|
|
120
|
+
await subtle().decrypt({ name: 'AES-GCM', iv: sealed.subarray(1, 1 + IV_BYTES), additionalData: enc(aad) }, await aesKey(rawKey), sealed.subarray(1 + IV_BYTES))
|
|
121
|
+
);
|
|
122
|
+
} catch {
|
|
123
|
+
throw new Error('Wrong key, or the file was altered');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function passphraseKey(passphrase, salt, iterations = PBKDF2_ITERATIONS) {
|
|
128
|
+
const base = await subtle().importKey('raw', enc(passphrase.normalize('NFKC')), 'PBKDF2', false, ['deriveBits']);
|
|
129
|
+
return new Uint8Array(await subtle().deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, base, 256));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function wrapVault(businessId, vaultKey, passphrase) {
|
|
133
|
+
const salt = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
134
|
+
const kek = await passphraseKey(passphrase, salt);
|
|
135
|
+
return { kdf_salt: toB64url(salt), kdf_iterations: PBKDF2_ITERATIONS, wrapped_key: toB64url(await seal(kek, vaultKey, AAD.vaultKey(businessId))) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function unlockVaultRecord(businessId, record, passphrase) {
|
|
139
|
+
const kek = await passphraseKey(passphrase, fromB64url(record.kdf_salt), record.kdf_iterations);
|
|
140
|
+
try {
|
|
141
|
+
return await open(kek, fromB64url(record.wrapped_key), AAD.vaultKey(businessId));
|
|
142
|
+
} catch {
|
|
143
|
+
throw new Error('Wrong passphrase');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `<url>#k=<document key>`: the link a recipient needs to open the file. */
|
|
148
|
+
export function linkWithKey(url, documentKey) {
|
|
149
|
+
return `${url}#k=${toB64url(documentKey)}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// API
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
export async function listDocuments(client, { businessId, status, folder } = {}) {
|
|
157
|
+
const data = await client.request('/documents' + q({ business_id: businessId, status, folder }));
|
|
158
|
+
return data.documents ?? [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The document (metadata only), who has it and the audit trail. */
|
|
162
|
+
export async function getDocument(client, documentId) {
|
|
163
|
+
return client.request('/documents/' + id(documentId));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function listDocumentVaults(client, { businessId } = {}) {
|
|
167
|
+
const data = await client.request('/documents/vault' + q({ business_id: businessId }));
|
|
168
|
+
return data.vaults ?? [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Create the business's documents vault (admin). Lose the passphrase and the documents are gone. */
|
|
172
|
+
export async function setupDocumentsVault(client, { businessId, passphrase }) {
|
|
173
|
+
if (!businessId) throw new Error('businessId is required');
|
|
174
|
+
if (!passphrase || passphrase.length < 10) throw new Error('Use a passphrase of at least 10 characters');
|
|
175
|
+
const existing = await listDocumentVaults(client, { businessId });
|
|
176
|
+
if (existing.length) throw new Error('This business already has a documents vault');
|
|
177
|
+
const vaultKey = newKey();
|
|
178
|
+
const data = await client.request('/documents/vault', json('POST', { business_id: businessId, ...(await wrapVault(businessId, vaultKey, passphrase)) }));
|
|
179
|
+
return { vaultKey, version: data.vault.version };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The vault key, from the passphrase. Tries every version, newest first. */
|
|
183
|
+
export async function unlockDocumentsVault(client, { businessId, passphrase }) {
|
|
184
|
+
if (!businessId) throw new Error('businessId is required');
|
|
185
|
+
const vaults = await listDocumentVaults(client, { businessId });
|
|
186
|
+
if (!vaults.length) throw new Error('This business has no documents vault yet; run `coinpay document vault-setup`');
|
|
187
|
+
for (const v of vaults) {
|
|
188
|
+
try {
|
|
189
|
+
return { vaultKey: await unlockVaultRecord(businessId, v, passphrase), version: v.version };
|
|
190
|
+
} catch {
|
|
191
|
+
// next version
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
throw new Error('Wrong passphrase');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function documentKey(client, doc, vault) {
|
|
198
|
+
return open(vault.vaultKey, fromB64url(doc.wrapped_key), AAD.documentKey(doc.business_id));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Encrypt and upload. `bytes` is the file content; `filename` decides its type. */
|
|
202
|
+
export async function uploadDocument(client, { businessId, vault, bytes, filename, title, message, folder }) {
|
|
203
|
+
const contentType = contentTypeFor(filename);
|
|
204
|
+
const plain = new Uint8Array(bytes);
|
|
205
|
+
const docId = globalThis.crypto.randomUUID();
|
|
206
|
+
const dk = newKey();
|
|
207
|
+
const meta = {
|
|
208
|
+
id: docId,
|
|
209
|
+
business_id: businessId,
|
|
210
|
+
title: title || String(filename).replace(/\.[A-Za-z0-9]{1,5}$/, ''),
|
|
211
|
+
message: message || null,
|
|
212
|
+
folder: normalizeFolder(folder),
|
|
213
|
+
filename: String(filename).split(/[\\/]/).pop(),
|
|
214
|
+
content_type: contentType,
|
|
215
|
+
bytes: plain.length,
|
|
216
|
+
sha256: await sha256Hex(plain),
|
|
217
|
+
wrapped_key: toB64url(await seal(vault.vaultKey, dk, AAD.documentKey(businessId))),
|
|
218
|
+
vault_version: vault.version,
|
|
219
|
+
};
|
|
220
|
+
const form = new FormData();
|
|
221
|
+
form.set('meta', JSON.stringify(meta));
|
|
222
|
+
form.set('file', new Blob([await seal(dk, plain, AAD.original(docId))], { type: 'application/octet-stream' }), 'sealed.bin');
|
|
223
|
+
const data = await client.requestForm('/documents', form);
|
|
224
|
+
return data.document;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Share with signers and viewers. With `vault`, each returned link carries the
|
|
229
|
+
* key; hand it to the person yourself. `emailKey: true` also puts the key in
|
|
230
|
+
* the invite email (convenient, but the mail path can then read the document).
|
|
231
|
+
*/
|
|
232
|
+
export async function shareDocument(client, documentId, { recipients, message, sendEmail, emailKey, vault }) {
|
|
233
|
+
const doc = (await getDocument(client, documentId)).document;
|
|
234
|
+
const dk = vault ? await documentKey(client, doc, vault) : null;
|
|
235
|
+
if (emailKey && !dk) throw new Error('emailKey needs the vault unlocked');
|
|
236
|
+
const body = { recipients };
|
|
237
|
+
if (message !== undefined) body.message = message;
|
|
238
|
+
if (sendEmail === false) body.send_email = false;
|
|
239
|
+
if (emailKey) body.email_document_key = toB64url(dk);
|
|
240
|
+
const data = await client.request(`/documents/${id(documentId)}/recipients`, json('POST', body));
|
|
241
|
+
return { ...data, recipients: data.recipients.map((r) => ({ ...r, link: dk ? linkWithKey(r.url, dk) : null })) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A new link for one recipient; the old one stops working. */
|
|
245
|
+
export async function newDocumentLink(client, documentId, recipientId, { sendEmail, emailKey, vault } = {}) {
|
|
246
|
+
const doc = (await getDocument(client, documentId)).document;
|
|
247
|
+
const dk = vault ? await documentKey(client, doc, vault) : null;
|
|
248
|
+
const body = {};
|
|
249
|
+
if (sendEmail === false) body.send_email = false;
|
|
250
|
+
if (emailKey && dk) body.email_document_key = toB64url(dk);
|
|
251
|
+
const data = await client.request(`/documents/${id(documentId)}/recipients/${id(recipientId)}`, json('POST', body));
|
|
252
|
+
return { url: data.url, link: dk ? linkWithKey(data.url, dk) : null };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function revokeDocumentRecipient(client, documentId, recipientId) {
|
|
256
|
+
return client.request(`/documents/${id(documentId)}/recipients/${id(recipientId)}`, { method: 'DELETE' });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function voidDocument(client, documentId, { reason } = {}) {
|
|
260
|
+
return client.request('/documents/' + id(documentId), json('PATCH', { action: 'void', ...(reason ? { reason } : {}) }));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** File a document under another folder (null = top level). */
|
|
264
|
+
export async function moveDocument(client, documentId, folder) {
|
|
265
|
+
return client.request('/documents/' + id(documentId), json('PATCH', { action: 'move', folder: normalizeFolder(folder) }));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function deleteDocument(client, documentId) {
|
|
269
|
+
return client.request('/documents/' + id(documentId), { method: 'DELETE' });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Every version of the document, newest first (metadata; the files stay sealed). */
|
|
273
|
+
export async function listDocumentVersions(client, documentId) {
|
|
274
|
+
const data = await client.request(`/documents/${id(documentId)}/versions`);
|
|
275
|
+
return data.versions ?? [];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Encrypt and upload the next version. Refused once anyone has signed;
|
|
280
|
+
* earlier versions stay in the history.
|
|
281
|
+
*/
|
|
282
|
+
export async function uploadDocumentVersion(client, documentId, { vault, bytes, filename, note }) {
|
|
283
|
+
const doc = (await getDocument(client, documentId)).document;
|
|
284
|
+
const contentType = contentTypeFor(filename);
|
|
285
|
+
const dk = await documentKey(client, doc, vault);
|
|
286
|
+
const plain = new Uint8Array(bytes);
|
|
287
|
+
const next = doc.current_version + 1;
|
|
288
|
+
const form = new FormData();
|
|
289
|
+
form.set(
|
|
290
|
+
'meta',
|
|
291
|
+
JSON.stringify({ version: next, filename: String(filename).split(/[\\/]/).pop(), content_type: contentType, bytes: plain.length, sha256: await sha256Hex(plain), note: note || null })
|
|
292
|
+
);
|
|
293
|
+
form.set('file', new Blob([await seal(dk, plain, AAD.version(documentId, next))], { type: 'application/octet-stream' }), 'sealed.bin');
|
|
294
|
+
return client.requestForm(`/documents/${id(documentId)}/versions`, form);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Download and decrypt the current file, an earlier `revision`, or the signed
|
|
299
|
+
* copy (`version: 'signed'`), checking its sha256.
|
|
300
|
+
*/
|
|
301
|
+
export async function downloadDocument(client, documentId, { version = 'original', revision, vault }) {
|
|
302
|
+
const data = await getDocument(client, documentId);
|
|
303
|
+
const doc = data.document;
|
|
304
|
+
const dk = await documentKey(client, doc, vault);
|
|
305
|
+
if (version === 'signed') {
|
|
306
|
+
const { bytes } = await client.requestBinary(`/documents/${id(documentId)}/file?version=signed`);
|
|
307
|
+
const plain = await open(dk, bytes, AAD.signed(documentId));
|
|
308
|
+
if (doc.signed_sha256 && (await sha256Hex(plain)) !== doc.signed_sha256) throw new Error('The file does not match its recorded sha256');
|
|
309
|
+
return { bytes: plain, filename: doc.filename.replace(/\.[A-Za-z0-9]{1,5}$/, '') + '-signed.pdf', document: doc };
|
|
310
|
+
}
|
|
311
|
+
const n = revision || doc.current_version;
|
|
312
|
+
const entry = (data.versions ?? []).find((v) => v.version === n) ?? (n === doc.current_version ? doc : null);
|
|
313
|
+
if (!entry) throw new Error('No such version');
|
|
314
|
+
const { bytes } = await client.requestBinary(`/documents/${id(documentId)}/file?n=${n}`);
|
|
315
|
+
const plain = await open(dk, bytes, AAD.version(documentId, n));
|
|
316
|
+
if ((await sha256Hex(plain)) !== entry.sha256) throw new Error('The file does not match its recorded sha256');
|
|
317
|
+
return { bytes: plain, filename: entry.filename, document: doc, revision: n };
|
|
318
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -114,6 +114,26 @@ 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
|
+
moveDocument,
|
|
133
|
+
} from './documents.js';
|
|
134
|
+
export { publishDirectory, planPublish } from './documents-publish.js';
|
|
135
|
+
export type { PublishEntry, PublishResult } from './documents-publish.js';
|
|
136
|
+
export type { CoinPayDocument, DocumentRecipient, DocumentVault, DocumentVersion, UnlockedVault } from './documents.js';
|
|
117
137
|
|
|
118
138
|
// Webhook exports
|
|
119
139
|
export {
|
package/src/index.js
CHANGED
|
@@ -128,6 +128,25 @@ 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
|
+
moveDocument,
|
|
147
|
+
} from './documents.js';
|
|
148
|
+
import { publishDirectory, planPublish } from './documents-publish.js';
|
|
149
|
+
|
|
131
150
|
// Wallet exports
|
|
132
151
|
import {
|
|
133
152
|
WalletClient,
|
|
@@ -312,6 +331,23 @@ export {
|
|
|
312
331
|
payPayRun,
|
|
313
332
|
recordPayRunPayments,
|
|
314
333
|
get1099Summary,
|
|
334
|
+
listDocuments,
|
|
335
|
+
getDocument,
|
|
336
|
+
listDocumentVaults,
|
|
337
|
+
setupDocumentsVault,
|
|
338
|
+
unlockDocumentsVault,
|
|
339
|
+
uploadDocument,
|
|
340
|
+
shareDocument,
|
|
341
|
+
newDocumentLink,
|
|
342
|
+
revokeDocumentRecipient,
|
|
343
|
+
voidDocument,
|
|
344
|
+
deleteDocument,
|
|
345
|
+
downloadDocument,
|
|
346
|
+
listDocumentVersions,
|
|
347
|
+
uploadDocumentVersion,
|
|
348
|
+
moveDocument,
|
|
349
|
+
publishDirectory,
|
|
350
|
+
planPublish,
|
|
315
351
|
|
|
316
352
|
// Wallet
|
|
317
353
|
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
|
|
|
@@ -386,6 +387,31 @@ export const TOOLS = [
|
|
|
386
387
|
inputSchema: { type: 'object', properties: { businessId: { type: 'string' }, year: { type: 'integer' } }, additionalProperties: false },
|
|
387
388
|
handler: async (args, { client }) => text(await contractors.get1099Summary(client, args)),
|
|
388
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
|
+
},
|
|
389
415
|
];
|
|
390
416
|
|
|
391
417
|
/** Answer one JSON-RPC message; null for a notification. */
|