@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 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.20.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 }>;
@@ -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. */