@profullstack/coinpay 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to `@profullstack/coinpay` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.21.0] - 2026-10-19
9
+
10
+ ### Added
11
+ - Documents, end-to-end encrypted: `coinpay document vault-setup|list|upload|share|get|link|revoke|download|void|delete`.
12
+ Files are encrypted on your machine (AES-256-GCM, one key per document, wrapped under the
13
+ business vault key, which is wrapped under a PBKDF2 passphrase); CoinPay stores only ciphertext.
14
+ `share` adds signers and viewers and prints each full link with its `#k=` key; the invite
15
+ emails omit the key unless `--email-key`. `download` decrypts and verifies the sha256.
16
+ Invites can be signers, editors (upload new versions) or viewers (`--signers/--editors/--viewers`).
17
+ Every version is kept: `versions <id>`, `new-version <id> <file>`, `download --revision N`.
18
+ - SDK: `listDocuments`, `getDocument`, `setupDocumentsVault`, `unlockDocumentsVault`,
19
+ `uploadDocument`, `shareDocument`, `newDocumentLink`, `revokeDocumentRecipient`,
20
+ `voidDocument`, `deleteDocument`, `downloadDocument`, `listDocumentVersions`, `uploadDocumentVersion`.
21
+ - MCP: `document_list`, `document_get`, `document_void` (metadata only; no keys).
22
+
8
23
  ## [0.19.0] - 2026-10-17
9
24
 
10
25
  ### Added
package/bin/coinpay.js CHANGED
@@ -66,6 +66,21 @@ import {
66
66
  recordPayRunPayments,
67
67
  get1099Summary,
68
68
  } from '../src/contractors.js';
69
+ import {
70
+ listDocuments,
71
+ getDocument,
72
+ setupDocumentsVault,
73
+ unlockDocumentsVault,
74
+ uploadDocument,
75
+ shareDocument,
76
+ newDocumentLink,
77
+ revokeDocumentRecipient,
78
+ voidDocument,
79
+ deleteDocument,
80
+ downloadDocument,
81
+ listDocumentVersions,
82
+ uploadDocumentVersion,
83
+ } from '../src/documents.js';
69
84
  import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'fs';
70
85
  import { execSync, spawn } from 'child_process';
71
86
  import { createInterface } from 'readline';
@@ -534,6 +549,22 @@ ${colors.cyan}Commands:${colors.reset}
534
549
  1099 Totals and who needs a 1099-NEC (--year 2026)
535
550
  offboard <id> End contracts and revoke the portal link
536
551
 
552
+ ${colors.bright}document${colors.reset} (end-to-end encrypted: CoinPay never sees the contents)
553
+ vault-setup Create the documents passphrase (--business-id, admins)
554
+ list Documents (--business-id) with signing progress
555
+ upload <file> Encrypt and upload: --business-id [--title] [--message]
556
+ share <id> --signers "Ada <ada@x.com>, bob@y.com" [--editors "..."] [--viewers "..."]
557
+ [--message] [--no-email] [--email-key puts the key in the email]
558
+ Prints each full link (with #k= key): send it privately
559
+ get <id> Status, people and audit trail
560
+ link <id> <recipient> New link for one person (old one stops working)
561
+ revoke <id> <recipient> Remove someone who has not signed
562
+ versions <id> The history: every uploaded version
563
+ new-version <id> <file> Encrypt and upload the next version [--note]
564
+ download <id> Decrypt to a file [--signed | --revision N] [--out path]
565
+ void <id> [--reason] delete <id>
566
+ Passphrase: COINPAY_DOCUMENTS_PASSPHRASE or a hidden prompt
567
+
537
568
  ${colors.bright}payrun${colors.reset}
538
569
  list Pay runs
539
570
  create Draft from fixed fees due + approved work [--date YYYY-MM-DD]
@@ -1277,6 +1308,147 @@ function usageExit(usage) {
1277
1308
  process.exit(1);
1278
1309
  }
1279
1310
 
1311
+ /** "Ada <ada@x.com>, bob@y.com" -> [{ name, email }] */
1312
+ function parsePeople(text, role) {
1313
+ return String(text || '')
1314
+ .split(',')
1315
+ .map((s) => s.trim())
1316
+ .filter(Boolean)
1317
+ .map((s) => {
1318
+ const m = /^(.*?)\s*<([^>]+)>$/.exec(s);
1319
+ const email = (m ? m[2] : s).trim();
1320
+ return { name: (m && m[1].trim()) || email, email, role };
1321
+ });
1322
+ }
1323
+
1324
+ async function documentsVault(client, businessId) {
1325
+ const passphrase = process.env.COINPAY_DOCUMENTS_PASSPHRASE || (await promptPassword('Documents passphrase: '));
1326
+ return unlockDocumentsVault(client, { businessId, passphrase });
1327
+ }
1328
+
1329
+ async function handleDocument(subcommand, args, flags) {
1330
+ const client = createClient();
1331
+ requireFlagValues(flags, ['business-id', 'title', 'message', 'signers', 'viewers', 'editors', 'reason', 'out', 'note', 'revision']);
1332
+ switch (subcommand) {
1333
+ case 'vault-setup': {
1334
+ if (!flags['business-id']) usageExit('coinpay document vault-setup --business-id <id>');
1335
+ print.warn('If this passphrase is lost, nobody (including CoinPay) can recover the documents.');
1336
+ const passphrase = process.env.COINPAY_DOCUMENTS_PASSPHRASE || (await promptPassword('New documents passphrase: '));
1337
+ if (!process.env.COINPAY_DOCUMENTS_PASSPHRASE && (await promptPassword('Repeat it: ')) !== passphrase) usageExit('The passphrases do not match');
1338
+ const vault = await setupDocumentsVault(client, { businessId: flags['business-id'], passphrase });
1339
+ return flags.json ? print.json({ version: vault.version }) : print.success('Documents vault created (version ' + vault.version + ')');
1340
+ }
1341
+ case 'list': {
1342
+ const rows = await listDocuments(client, { businessId: flags['business-id'] });
1343
+ if (flags.json) return print.json(rows);
1344
+ if (!rows.length) return print.info('No documents');
1345
+ for (const d of rows) {
1346
+ const progress = d.signers ? ' ' + d.signed + '/' + d.signers + ' signed' : '';
1347
+ print.info(d.id + ' ' + d.title + ' ' + d.status + progress);
1348
+ }
1349
+ return;
1350
+ }
1351
+ case 'upload': {
1352
+ if (args.length !== 1 || !flags['business-id']) usageExit('coinpay document upload <file> --business-id <id> [--title "..."] [--message "..."]');
1353
+ const vault = await documentsVault(client, flags['business-id']);
1354
+ const doc = await uploadDocument(client, {
1355
+ businessId: flags['business-id'],
1356
+ vault,
1357
+ bytes: readFileSync(args[0]),
1358
+ filename: args[0],
1359
+ title: flags.title,
1360
+ message: flags.message,
1361
+ });
1362
+ if (flags.json) return print.json(doc);
1363
+ print.success('Uploaded (encrypted): ' + doc.id);
1364
+ return print.info('Send it with: coinpay document share ' + doc.id + ' --signers "Name <email>"');
1365
+ }
1366
+ case 'share': {
1367
+ const recipients = [...parsePeople(flags.signers, 'signer'), ...parsePeople(flags.editors, 'editor'), ...parsePeople(flags.viewers, 'viewer')];
1368
+ if (args.length !== 1 || !recipients.length) usageExit('coinpay document share <id> --signers "Ada <ada@x.com>" [--editors "..."] [--viewers "..."] [--message] [--no-email] [--email-key]');
1369
+ const { document } = await getDocument(client, args[0]);
1370
+ const vault = await documentsVault(client, document.business_id);
1371
+ const result = await shareDocument(client, args[0], {
1372
+ recipients,
1373
+ message: flags.message,
1374
+ sendEmail: !flags['no-email'],
1375
+ emailKey: !!flags['email-key'],
1376
+ vault,
1377
+ });
1378
+ if (flags.json) return print.json(result);
1379
+ print.success('Shared; document is now ' + result.status);
1380
+ if (!flags['email-key']) print.warn('The emails do NOT contain the key. Send each person their full link below on another channel:');
1381
+ for (const r of result.recipients) print.info(r.role.padEnd(6) + ' ' + r.email + ' ' + r.link);
1382
+ return;
1383
+ }
1384
+ case 'get': {
1385
+ if (args.length !== 1) usageExit('coinpay document get <id>');
1386
+ const data = await getDocument(client, args[0]);
1387
+ if (flags.json) return print.json(data);
1388
+ const d = data.document;
1389
+ print.info(d.title + ' ' + d.status + ' ' + d.filename + ' (' + d.bytes + ' bytes)');
1390
+ print.info('sha256 ' + d.sha256 + (d.signed_sha256 ? '\nsigned sha256 ' + d.signed_sha256 : ''));
1391
+ for (const r of data.recipients) {
1392
+ if (r.revoked_at) continue;
1393
+ print.info(' ' + r.id + ' ' + r.role + ' ' + r.name + ' <' + r.email + '> ' + r.status + (r.signed_at ? ' ' + r.signed_at : ''));
1394
+ }
1395
+ for (const e of data.events) print.info(' ' + e.created_at + ' ' + e.event + (e.actor_label ? ' ' + e.actor_label : '') + (e.ip ? ' ' + e.ip : ''));
1396
+ return;
1397
+ }
1398
+ case 'link': {
1399
+ if (args.length !== 2) usageExit('coinpay document link <id> <recipient-id> [--no-email] [--email-key]');
1400
+ const { document } = await getDocument(client, args[0]);
1401
+ const vault = await documentsVault(client, document.business_id);
1402
+ const result = await newDocumentLink(client, args[0], args[1], { sendEmail: !flags['no-email'], emailKey: !!flags['email-key'], vault });
1403
+ return flags.json ? print.json(result) : print.info(result.link);
1404
+ }
1405
+ case 'revoke': {
1406
+ if (args.length !== 2) usageExit('coinpay document revoke <id> <recipient-id>');
1407
+ const result = await revokeDocumentRecipient(client, args[0], args[1]);
1408
+ return flags.json ? print.json(result) : print.success('Revoked; document is now ' + result.status);
1409
+ }
1410
+ case 'versions': {
1411
+ if (args.length !== 1) usageExit('coinpay document versions <id>');
1412
+ const rows = await listDocumentVersions(client, args[0]);
1413
+ if (flags.json) return print.json(rows);
1414
+ for (const v of rows) print.info('v' + v.version + ' ' + v.created_at + ' ' + v.filename + ' ' + v.bytes + ' bytes' + (v.uploaded_by ? ' ' + v.uploaded_by : '') + (v.note ? ' ' + v.note : ''));
1415
+ return;
1416
+ }
1417
+ case 'new-version': {
1418
+ if (args.length !== 2) usageExit('coinpay document new-version <id> <file> [--note "what changed"]');
1419
+ const { document } = await getDocument(client, args[0]);
1420
+ const vault = await documentsVault(client, document.business_id);
1421
+ const result = await uploadDocumentVersion(client, args[0], { vault, bytes: readFileSync(args[1]), filename: args[1], note: flags.note });
1422
+ return flags.json ? print.json(result) : print.success('Uploaded version ' + result.version + ' (encrypted)');
1423
+ }
1424
+ case 'download': {
1425
+ if (args.length !== 1) usageExit('coinpay document download <id> [--signed | --revision N] [--out path]');
1426
+ const { document } = await getDocument(client, args[0]);
1427
+ const vault = await documentsVault(client, document.business_id);
1428
+ const file = await downloadDocument(client, args[0], {
1429
+ version: flags.signed ? 'signed' : 'original',
1430
+ revision: flags.revision ? Number(flags.revision) : undefined,
1431
+ vault,
1432
+ });
1433
+ const out = flags.out || file.filename;
1434
+ writeFileSync(out, file.bytes, { mode: 0o600 });
1435
+ return print.success('Decrypted ' + file.bytes.length + ' bytes to ' + out + ' (sha256 verified)');
1436
+ }
1437
+ case 'void': {
1438
+ if (args.length !== 1) usageExit('coinpay document void <id> [--reason "..."]');
1439
+ await voidDocument(client, args[0], { reason: flags.reason });
1440
+ return print.success('Voided');
1441
+ }
1442
+ case 'delete': {
1443
+ if (args.length !== 1) usageExit('coinpay document delete <id>');
1444
+ await deleteDocument(client, args[0]);
1445
+ return print.success('Deleted');
1446
+ }
1447
+ default:
1448
+ usageExit('coinpay document <vault-setup|list|upload|share|get|versions|new-version|link|revoke|download|void|delete>');
1449
+ }
1450
+ }
1451
+
1280
1452
  async function handleContractor(subcommand, args, flags) {
1281
1453
  const client = createClient();
1282
1454
  requireFlagValues(flags, ['business-id', 'name', 'email', 'country', 'title', 'kind', 'rate', 'frequency', 'start', 'end', 'note', 'signature', 'reason', 'amount', 'contract', 'description', 'status', 'year']);
@@ -5257,6 +5429,11 @@ async function handleLightning(subcommand, args, flags) {
5257
5429
  await handleProject(subcommand, args, flags);
5258
5430
  break;
5259
5431
 
5432
+ case 'document':
5433
+ case 'documents':
5434
+ await handleDocument(subcommand, args, flags);
5435
+ break;
5436
+
5260
5437
  case 'contractor':
5261
5438
  case 'contractors':
5262
5439
  await handleContractor(subcommand, args, flags);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "CoinPay SDK & CLI \u2014 Accept cryptocurrency and card payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet, swap and a live finances dashboard",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -0,0 +1,86 @@
1
+ import type { CoinPayClient } from './client.js';
2
+
3
+ export interface CoinPayDocument {
4
+ id: string;
5
+ business_id: string;
6
+ title: string;
7
+ message: string | null;
8
+ filename: string;
9
+ content_type: string;
10
+ bytes: number;
11
+ sha256: string;
12
+ wrapped_key: string;
13
+ vault_version: number;
14
+ current_version: number;
15
+ status: 'draft' | 'shared' | 'out_for_signature' | 'completed' | 'declined' | 'voided';
16
+ signed_sha256: string | null;
17
+ signed_bytes: number | null;
18
+ completed_at: string | null;
19
+ voided_at: string | null;
20
+ created_at: string;
21
+ signers?: number;
22
+ signed?: number;
23
+ viewers?: number;
24
+ }
25
+
26
+ export interface DocumentRecipient {
27
+ id: string;
28
+ name: string;
29
+ email: string;
30
+ role: 'signer' | 'viewer' | 'editor';
31
+ status: 'pending' | 'viewed' | 'signed' | 'declined';
32
+ signed_at: string | null;
33
+ signature_name: string | null;
34
+ revoked_at: string | null;
35
+ /** The link without the key (server copy). */
36
+ url?: string;
37
+ /** The link with `#k=<key>`, when the vault was unlocked. Hand it over yourself. */
38
+ link?: string | null;
39
+ }
40
+
41
+ export interface DocumentVersion {
42
+ version: number;
43
+ filename: string;
44
+ content_type: string;
45
+ bytes: number;
46
+ sha256: string;
47
+ note: string | null;
48
+ uploaded_by: string | null;
49
+ created_at: string;
50
+ }
51
+
52
+ export interface DocumentVault {
53
+ version: number;
54
+ kdf_salt: string;
55
+ kdf_iterations: number;
56
+ wrapped_key: string;
57
+ created_at: string;
58
+ }
59
+
60
+ export interface UnlockedVault {
61
+ vaultKey: Uint8Array;
62
+ version: number;
63
+ }
64
+
65
+ export function listDocuments(client: CoinPayClient, opts?: { businessId?: string; status?: string }): Promise<CoinPayDocument[]>;
66
+ export function getDocument(client: CoinPayClient, documentId: string): Promise<{ document: CoinPayDocument & { signed_available: boolean }; recipients: DocumentRecipient[]; events: unknown[]; versions: DocumentVersion[]; certificate: unknown }>;
67
+ export function listDocumentVaults(client: CoinPayClient, opts?: { businessId?: string }): Promise<DocumentVault[]>;
68
+ export function setupDocumentsVault(client: CoinPayClient, opts: { businessId: string; passphrase: string }): Promise<UnlockedVault>;
69
+ export function unlockDocumentsVault(client: CoinPayClient, opts: { businessId: string; passphrase: string }): Promise<UnlockedVault>;
70
+ export function uploadDocument(client: CoinPayClient, opts: { businessId: string; vault: UnlockedVault; bytes: Uint8Array; filename: string; title?: string; message?: string }): Promise<CoinPayDocument>;
71
+ export function shareDocument(
72
+ client: CoinPayClient,
73
+ documentId: string,
74
+ opts: { recipients: { name: string; email: string; role?: 'signer' | 'viewer' | 'editor'; expires_in_days?: number }[]; message?: string | null; sendEmail?: boolean; emailKey?: boolean; vault?: UnlockedVault }
75
+ ): Promise<{ status: string; recipients: DocumentRecipient[] }>;
76
+ export function newDocumentLink(client: CoinPayClient, documentId: string, recipientId: string, opts?: { sendEmail?: boolean; emailKey?: boolean; vault?: UnlockedVault }): Promise<{ url: string; link: string | null }>;
77
+ export function revokeDocumentRecipient(client: CoinPayClient, documentId: string, recipientId: string): Promise<{ status: string }>;
78
+ export function voidDocument(client: CoinPayClient, documentId: string, opts?: { reason?: string }): Promise<unknown>;
79
+ export function deleteDocument(client: CoinPayClient, documentId: string): Promise<unknown>;
80
+ export function listDocumentVersions(client: CoinPayClient, documentId: string): Promise<DocumentVersion[]>;
81
+ export function uploadDocumentVersion(client: CoinPayClient, documentId: string, opts: { vault: UnlockedVault; bytes: Uint8Array; filename: string; note?: string }): Promise<{ version: number }>;
82
+ export function downloadDocument(
83
+ client: CoinPayClient,
84
+ documentId: string,
85
+ opts: { version?: 'original' | 'signed'; revision?: number; vault: UnlockedVault }
86
+ ): Promise<{ bytes: Uint8Array; filename: string; document: CoinPayDocument; revision?: number }>;
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Documents: upload, share by private link and collect e-signatures, end-to-end
3
+ * encrypted. CoinPay stores only ciphertext; every key is handled here, on
4
+ * your machine, exactly as the web app does it in the browser
5
+ * (src/lib/documents/e2e.ts in coinpayportal):
6
+ *
7
+ * - each document has its own random AES-256-GCM key (DK);
8
+ * - the business keeps DK wrapped under its vault key (VK);
9
+ * - VK is wrapped under a key derived from the documents passphrase (PBKDF2-SHA256);
10
+ * - a recipient link carries DK after `#k=`, which is never sent to the server.
11
+ *
12
+ * Sealed blob format: 0x01 || 12-byte IV || AES-GCM ciphertext+tag.
13
+ */
14
+
15
+ const VERSION = 0x01;
16
+ const IV_BYTES = 12;
17
+ export const PBKDF2_ITERATIONS = 600_000;
18
+
19
+ const subtle = () => globalThis.crypto.subtle;
20
+ const enc = (s) => new TextEncoder().encode(s);
21
+ const q = (params) => {
22
+ const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '');
23
+ return entries.length ? '?' + new URLSearchParams(entries.map(([k, v]) => [k, String(v)])).toString() : '';
24
+ };
25
+ const json = (method, body) => ({ method, body: JSON.stringify(body) });
26
+ const id = (v) => encodeURIComponent(v);
27
+
28
+ export const DOCUMENT_TYPES = {
29
+ pdf: 'application/pdf',
30
+ png: 'image/png',
31
+ jpg: 'image/jpeg',
32
+ jpeg: 'image/jpeg',
33
+ txt: 'text/plain',
34
+ md: 'text/plain',
35
+ csv: 'text/csv',
36
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
37
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
38
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
39
+ };
40
+
41
+ export const AAD = {
42
+ original: (documentId) => `coinpay-doc:original:${documentId}`,
43
+ version: (documentId, n) => (n === 1 ? `coinpay-doc:original:${documentId}` : `coinpay-doc:original:${documentId}:v${n}`),
44
+ signed: (documentId) => `coinpay-doc:signed:${documentId}`,
45
+ documentKey: (businessId) => `coinpay-doc:dk:${businessId}`,
46
+ vaultKey: (businessId) => `coinpay-doc:vk:${businessId}`,
47
+ };
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Crypto (byte-for-byte the same as the web app)
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export function toB64url(bytes) {
54
+ return Buffer.from(bytes).toString('base64url');
55
+ }
56
+
57
+ export function fromB64url(text) {
58
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new Error('Not base64url');
59
+ return new Uint8Array(Buffer.from(text, 'base64url'));
60
+ }
61
+
62
+ export async function sha256Hex(data) {
63
+ return Buffer.from(await subtle().digest('SHA-256', data)).toString('hex');
64
+ }
65
+
66
+ async function aesKey(raw) {
67
+ if (raw.length !== 32) throw new Error('Expected a 256-bit key');
68
+ return subtle().importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
69
+ }
70
+
71
+ export function newKey() {
72
+ return globalThis.crypto.getRandomValues(new Uint8Array(32));
73
+ }
74
+
75
+ export async function seal(rawKey, plain, aad) {
76
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES));
77
+ const ct = new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv, additionalData: enc(aad) }, await aesKey(rawKey), plain));
78
+ const out = new Uint8Array(1 + IV_BYTES + ct.length);
79
+ out[0] = VERSION;
80
+ out.set(iv, 1);
81
+ out.set(ct, 1 + IV_BYTES);
82
+ return out;
83
+ }
84
+
85
+ export async function open(rawKey, sealed, aad) {
86
+ if (sealed.length < 1 + IV_BYTES + 16 || sealed[0] !== VERSION) throw new Error('Not an encrypted document');
87
+ try {
88
+ return new Uint8Array(
89
+ await subtle().decrypt({ name: 'AES-GCM', iv: sealed.subarray(1, 1 + IV_BYTES), additionalData: enc(aad) }, await aesKey(rawKey), sealed.subarray(1 + IV_BYTES))
90
+ );
91
+ } catch {
92
+ throw new Error('Wrong key, or the file was altered');
93
+ }
94
+ }
95
+
96
+ export async function passphraseKey(passphrase, salt, iterations = PBKDF2_ITERATIONS) {
97
+ const base = await subtle().importKey('raw', enc(passphrase.normalize('NFKC')), 'PBKDF2', false, ['deriveBits']);
98
+ return new Uint8Array(await subtle().deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, base, 256));
99
+ }
100
+
101
+ export async function wrapVault(businessId, vaultKey, passphrase) {
102
+ const salt = globalThis.crypto.getRandomValues(new Uint8Array(16));
103
+ const kek = await passphraseKey(passphrase, salt);
104
+ return { kdf_salt: toB64url(salt), kdf_iterations: PBKDF2_ITERATIONS, wrapped_key: toB64url(await seal(kek, vaultKey, AAD.vaultKey(businessId))) };
105
+ }
106
+
107
+ export async function unlockVaultRecord(businessId, record, passphrase) {
108
+ const kek = await passphraseKey(passphrase, fromB64url(record.kdf_salt), record.kdf_iterations);
109
+ try {
110
+ return await open(kek, fromB64url(record.wrapped_key), AAD.vaultKey(businessId));
111
+ } catch {
112
+ throw new Error('Wrong passphrase');
113
+ }
114
+ }
115
+
116
+ /** `<url>#k=<document key>`: the link a recipient needs to open the file. */
117
+ export function linkWithKey(url, documentKey) {
118
+ return `${url}#k=${toB64url(documentKey)}`;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // API
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export async function listDocuments(client, { businessId, status } = {}) {
126
+ const data = await client.request('/documents' + q({ business_id: businessId, status }));
127
+ return data.documents ?? [];
128
+ }
129
+
130
+ /** The document (metadata only), who has it and the audit trail. */
131
+ export async function getDocument(client, documentId) {
132
+ return client.request('/documents/' + id(documentId));
133
+ }
134
+
135
+ export async function listDocumentVaults(client, { businessId } = {}) {
136
+ const data = await client.request('/documents/vault' + q({ business_id: businessId }));
137
+ return data.vaults ?? [];
138
+ }
139
+
140
+ /** Create the business's documents vault (admin). Lose the passphrase and the documents are gone. */
141
+ export async function setupDocumentsVault(client, { businessId, passphrase }) {
142
+ if (!businessId) throw new Error('businessId is required');
143
+ if (!passphrase || passphrase.length < 10) throw new Error('Use a passphrase of at least 10 characters');
144
+ const existing = await listDocumentVaults(client, { businessId });
145
+ if (existing.length) throw new Error('This business already has a documents vault');
146
+ const vaultKey = newKey();
147
+ const data = await client.request('/documents/vault', json('POST', { business_id: businessId, ...(await wrapVault(businessId, vaultKey, passphrase)) }));
148
+ return { vaultKey, version: data.vault.version };
149
+ }
150
+
151
+ /** The vault key, from the passphrase. Tries every version, newest first. */
152
+ export async function unlockDocumentsVault(client, { businessId, passphrase }) {
153
+ if (!businessId) throw new Error('businessId is required');
154
+ const vaults = await listDocumentVaults(client, { businessId });
155
+ if (!vaults.length) throw new Error('This business has no documents vault yet; run `coinpay document vault-setup`');
156
+ for (const v of vaults) {
157
+ try {
158
+ return { vaultKey: await unlockVaultRecord(businessId, v, passphrase), version: v.version };
159
+ } catch {
160
+ // next version
161
+ }
162
+ }
163
+ throw new Error('Wrong passphrase');
164
+ }
165
+
166
+ async function documentKey(client, doc, vault) {
167
+ return open(vault.vaultKey, fromB64url(doc.wrapped_key), AAD.documentKey(doc.business_id));
168
+ }
169
+
170
+ /** Encrypt and upload. `bytes` is the file content; `filename` decides its type. */
171
+ export async function uploadDocument(client, { businessId, vault, bytes, filename, title, message }) {
172
+ const ext = String(filename).toLowerCase().split('.').pop();
173
+ const contentType = DOCUMENT_TYPES[ext];
174
+ if (!contentType) throw new Error('Unsupported file type: use ' + Object.keys(DOCUMENT_TYPES).join(', '));
175
+ const plain = new Uint8Array(bytes);
176
+ const docId = globalThis.crypto.randomUUID();
177
+ const dk = newKey();
178
+ const meta = {
179
+ id: docId,
180
+ business_id: businessId,
181
+ title: title || String(filename).replace(/\.[A-Za-z0-9]{1,5}$/, ''),
182
+ message: message || null,
183
+ filename: String(filename).split(/[\\/]/).pop(),
184
+ content_type: contentType,
185
+ bytes: plain.length,
186
+ sha256: await sha256Hex(plain),
187
+ wrapped_key: toB64url(await seal(vault.vaultKey, dk, AAD.documentKey(businessId))),
188
+ vault_version: vault.version,
189
+ };
190
+ const form = new FormData();
191
+ form.set('meta', JSON.stringify(meta));
192
+ form.set('file', new Blob([await seal(dk, plain, AAD.original(docId))], { type: 'application/octet-stream' }), 'sealed.bin');
193
+ const data = await client.requestForm('/documents', form);
194
+ return data.document;
195
+ }
196
+
197
+ /**
198
+ * Share with signers and viewers. With `vault`, each returned link carries the
199
+ * key; hand it to the person yourself. `emailKey: true` also puts the key in
200
+ * the invite email (convenient, but the mail path can then read the document).
201
+ */
202
+ export async function shareDocument(client, documentId, { recipients, message, sendEmail, emailKey, vault }) {
203
+ const doc = (await getDocument(client, documentId)).document;
204
+ const dk = vault ? await documentKey(client, doc, vault) : null;
205
+ if (emailKey && !dk) throw new Error('emailKey needs the vault unlocked');
206
+ const body = { recipients };
207
+ if (message !== undefined) body.message = message;
208
+ if (sendEmail === false) body.send_email = false;
209
+ if (emailKey) body.email_document_key = toB64url(dk);
210
+ const data = await client.request(`/documents/${id(documentId)}/recipients`, json('POST', body));
211
+ return { ...data, recipients: data.recipients.map((r) => ({ ...r, link: dk ? linkWithKey(r.url, dk) : null })) };
212
+ }
213
+
214
+ /** A new link for one recipient; the old one stops working. */
215
+ export async function newDocumentLink(client, documentId, recipientId, { sendEmail, emailKey, vault } = {}) {
216
+ const doc = (await getDocument(client, documentId)).document;
217
+ const dk = vault ? await documentKey(client, doc, vault) : null;
218
+ const body = {};
219
+ if (sendEmail === false) body.send_email = false;
220
+ if (emailKey && dk) body.email_document_key = toB64url(dk);
221
+ const data = await client.request(`/documents/${id(documentId)}/recipients/${id(recipientId)}`, json('POST', body));
222
+ return { url: data.url, link: dk ? linkWithKey(data.url, dk) : null };
223
+ }
224
+
225
+ export async function revokeDocumentRecipient(client, documentId, recipientId) {
226
+ return client.request(`/documents/${id(documentId)}/recipients/${id(recipientId)}`, { method: 'DELETE' });
227
+ }
228
+
229
+ export async function voidDocument(client, documentId, { reason } = {}) {
230
+ return client.request('/documents/' + id(documentId), json('PATCH', { action: 'void', ...(reason ? { reason } : {}) }));
231
+ }
232
+
233
+ export async function deleteDocument(client, documentId) {
234
+ return client.request('/documents/' + id(documentId), { method: 'DELETE' });
235
+ }
236
+
237
+ /** Every version of the document, newest first (metadata; the files stay sealed). */
238
+ export async function listDocumentVersions(client, documentId) {
239
+ const data = await client.request(`/documents/${id(documentId)}/versions`);
240
+ return data.versions ?? [];
241
+ }
242
+
243
+ /**
244
+ * Encrypt and upload the next version. Refused once anyone has signed;
245
+ * earlier versions stay in the history.
246
+ */
247
+ export async function uploadDocumentVersion(client, documentId, { vault, bytes, filename, note }) {
248
+ const doc = (await getDocument(client, documentId)).document;
249
+ const ext = String(filename).toLowerCase().split('.').pop();
250
+ const contentType = DOCUMENT_TYPES[ext];
251
+ if (!contentType) throw new Error('Unsupported file type: use ' + Object.keys(DOCUMENT_TYPES).join(', '));
252
+ const dk = await documentKey(client, doc, vault);
253
+ const plain = new Uint8Array(bytes);
254
+ const next = doc.current_version + 1;
255
+ const form = new FormData();
256
+ form.set(
257
+ 'meta',
258
+ JSON.stringify({ version: next, filename: String(filename).split(/[\\/]/).pop(), content_type: contentType, bytes: plain.length, sha256: await sha256Hex(plain), note: note || null })
259
+ );
260
+ form.set('file', new Blob([await seal(dk, plain, AAD.version(documentId, next))], { type: 'application/octet-stream' }), 'sealed.bin');
261
+ return client.requestForm(`/documents/${id(documentId)}/versions`, form);
262
+ }
263
+
264
+ /**
265
+ * Download and decrypt the current file, an earlier `revision`, or the signed
266
+ * copy (`version: 'signed'`), checking its sha256.
267
+ */
268
+ export async function downloadDocument(client, documentId, { version = 'original', revision, vault }) {
269
+ const data = await getDocument(client, documentId);
270
+ const doc = data.document;
271
+ const dk = await documentKey(client, doc, vault);
272
+ if (version === 'signed') {
273
+ const { bytes } = await client.requestBinary(`/documents/${id(documentId)}/file?version=signed`);
274
+ const plain = await open(dk, bytes, AAD.signed(documentId));
275
+ if (doc.signed_sha256 && (await sha256Hex(plain)) !== doc.signed_sha256) throw new Error('The file does not match its recorded sha256');
276
+ return { bytes: plain, filename: doc.filename.replace(/\.[A-Za-z0-9]{1,5}$/, '') + '-signed.pdf', document: doc };
277
+ }
278
+ const n = revision || doc.current_version;
279
+ const entry = (data.versions ?? []).find((v) => v.version === n) ?? (n === doc.current_version ? doc : null);
280
+ if (!entry) throw new Error('No such version');
281
+ const { bytes } = await client.requestBinary(`/documents/${id(documentId)}/file?n=${n}`);
282
+ const plain = await open(dk, bytes, AAD.version(documentId, n));
283
+ if ((await sha256Hex(plain)) !== entry.sha256) throw new Error('The file does not match its recorded sha256');
284
+ return { bytes: plain, filename: entry.filename, document: doc, revision: n };
285
+ }
package/src/index.d.ts CHANGED
@@ -114,6 +114,23 @@ export {
114
114
  get1099Summary,
115
115
  } from './contractors.js';
116
116
  export type { Contractor, ContractorContract, ContractorMilestone, ContractorWorkItem, PayRun, PayRunItem, PayOutcome } from './contractors.js';
117
+ export {
118
+ listDocuments,
119
+ getDocument,
120
+ listDocumentVaults,
121
+ setupDocumentsVault,
122
+ unlockDocumentsVault,
123
+ uploadDocument,
124
+ shareDocument,
125
+ newDocumentLink,
126
+ revokeDocumentRecipient,
127
+ voidDocument,
128
+ deleteDocument,
129
+ downloadDocument,
130
+ listDocumentVersions,
131
+ uploadDocumentVersion,
132
+ } from './documents.js';
133
+ export type { CoinPayDocument, DocumentRecipient, DocumentVault, DocumentVersion, UnlockedVault } from './documents.js';
117
134
 
118
135
  // Webhook exports
119
136
  export {
package/src/index.js CHANGED
@@ -128,6 +128,23 @@ import {
128
128
  get1099Summary,
129
129
  } from './contractors.js';
130
130
 
131
+ import {
132
+ listDocuments,
133
+ getDocument,
134
+ listDocumentVaults,
135
+ setupDocumentsVault,
136
+ unlockDocumentsVault,
137
+ uploadDocument,
138
+ shareDocument,
139
+ newDocumentLink,
140
+ revokeDocumentRecipient,
141
+ voidDocument,
142
+ deleteDocument,
143
+ downloadDocument,
144
+ listDocumentVersions,
145
+ uploadDocumentVersion,
146
+ } from './documents.js';
147
+
131
148
  // Wallet exports
132
149
  import {
133
150
  WalletClient,
@@ -312,6 +329,20 @@ export {
312
329
  payPayRun,
313
330
  recordPayRunPayments,
314
331
  get1099Summary,
332
+ listDocuments,
333
+ getDocument,
334
+ listDocumentVaults,
335
+ setupDocumentsVault,
336
+ unlockDocumentsVault,
337
+ uploadDocument,
338
+ shareDocument,
339
+ newDocumentLink,
340
+ revokeDocumentRecipient,
341
+ voidDocument,
342
+ deleteDocument,
343
+ downloadDocument,
344
+ listDocumentVersions,
345
+ uploadDocumentVersion,
315
346
 
316
347
  // Wallet
317
348
  WalletClient,
package/src/mcp.js CHANGED
@@ -18,6 +18,7 @@ import * as sf from './statements-fetch.js';
18
18
  import { listInvoices, getInvoice, createInvoice } from './invoices.js';
19
19
  import * as projects from './projects.js';
20
20
  import * as contractors from './contractors.js';
21
+ import * as documents from './documents.js';
21
22
 
22
23
  export const PROTOCOL_VERSION = '2025-06-18';
23
24
 
@@ -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. */