@profullstack/coinpay 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to `@profullstack/coinpay` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.21.0] - 2026-10-19
9
+
10
+ ### Added
11
+ - Documents, end-to-end encrypted: `coinpay document vault-setup|list|upload|share|get|link|revoke|download|void|delete`.
12
+ Files are encrypted on your machine (AES-256-GCM, one key per document, wrapped under the
13
+ business vault key, which is wrapped under a PBKDF2 passphrase); CoinPay stores only ciphertext.
14
+ `share` adds signers and viewers and prints each full link with its `#k=` key; the invite
15
+ emails omit the key unless `--email-key`. `download` decrypts and verifies the sha256.
16
+ Invites can be signers, editors (upload new versions) or viewers (`--signers/--editors/--viewers`).
17
+ Every version is kept: `versions <id>`, `new-version <id> <file>`, `download --revision N`.
18
+ - SDK: `listDocuments`, `getDocument`, `setupDocumentsVault`, `unlockDocumentsVault`,
19
+ `uploadDocument`, `shareDocument`, `newDocumentLink`, `revokeDocumentRecipient`,
20
+ `voidDocument`, `deleteDocument`, `downloadDocument`, `listDocumentVersions`, `uploadDocumentVersion`.
21
+ - MCP: `document_list`, `document_get`, `document_void` (metadata only; no keys).
22
+
8
23
  ## [0.19.0] - 2026-10-17
9
24
 
10
25
  ### Added
package/bin/coinpay.js CHANGED
@@ -66,6 +66,21 @@ import {
66
66
  recordPayRunPayments,
67
67
  get1099Summary,
68
68
  } from '../src/contractors.js';
69
+ import {
70
+ listDocuments,
71
+ getDocument,
72
+ setupDocumentsVault,
73
+ unlockDocumentsVault,
74
+ uploadDocument,
75
+ shareDocument,
76
+ newDocumentLink,
77
+ revokeDocumentRecipient,
78
+ voidDocument,
79
+ deleteDocument,
80
+ downloadDocument,
81
+ listDocumentVersions,
82
+ uploadDocumentVersion,
83
+ } from '../src/documents.js';
69
84
  import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'fs';
70
85
  import { execSync, spawn } from 'child_process';
71
86
  import { createInterface } from 'readline';
@@ -534,6 +549,22 @@ ${colors.cyan}Commands:${colors.reset}
534
549
  1099 Totals and who needs a 1099-NEC (--year 2026)
535
550
  offboard <id> End contracts and revoke the portal link
536
551
 
552
+ ${colors.bright}document${colors.reset} (end-to-end encrypted: CoinPay never sees the contents)
553
+ vault-setup Create the documents passphrase (--business-id, admins)
554
+ list Documents (--business-id) with signing progress
555
+ upload <file> Encrypt and upload: --business-id [--title] [--message]
556
+ share <id> --signers "Ada <ada@x.com>, bob@y.com" [--editors "..."] [--viewers "..."]
557
+ [--message] [--no-email] [--email-key puts the key in the email]
558
+ Prints each full link (with #k= key): send it privately
559
+ get <id> Status, people and audit trail
560
+ link <id> <recipient> New link for one person (old one stops working)
561
+ revoke <id> <recipient> Remove someone who has not signed
562
+ versions <id> The history: every uploaded version
563
+ new-version <id> <file> Encrypt and upload the next version [--note]
564
+ download <id> Decrypt to a file [--signed | --revision N] [--out path]
565
+ void <id> [--reason] delete <id>
566
+ Passphrase: COINPAY_DOCUMENTS_PASSPHRASE or a hidden prompt
567
+
537
568
  ${colors.bright}payrun${colors.reset}
538
569
  list Pay runs
539
570
  create Draft from fixed fees due + approved work [--date YYYY-MM-DD]
@@ -682,7 +713,7 @@ ${colors.cyan}Commands:${colors.reset}
682
713
  due [--days 7] What is due and overdue across every account (--days up to 60)
683
714
  recurring Every recurring charge detected, with next date and monthly cost
684
715
  due-day <id> <1-31|clear> A card/loan's statement due day (--amount 810.46, or balance)
685
- daily [show|on|off|send-now] Daily summary email: due soon, new activity, position (--hour 7 --to …)
716
+ daily [show|on|off|send-now] Daily summary email: spend vs target, top cuts, due soon, position (--hour 7 --to …, --target 100)
686
717
 
687
718
  ${colors.bright}escrow${colors.reset}
688
719
  create Create a new escrow
@@ -1277,6 +1308,147 @@ function usageExit(usage) {
1277
1308
  process.exit(1);
1278
1309
  }
1279
1310
 
1311
+ /** "Ada <ada@x.com>, bob@y.com" -> [{ name, email }] */
1312
+ function parsePeople(text, role) {
1313
+ return String(text || '')
1314
+ .split(',')
1315
+ .map((s) => s.trim())
1316
+ .filter(Boolean)
1317
+ .map((s) => {
1318
+ const m = /^(.*?)\s*<([^>]+)>$/.exec(s);
1319
+ const email = (m ? m[2] : s).trim();
1320
+ return { name: (m && m[1].trim()) || email, email, role };
1321
+ });
1322
+ }
1323
+
1324
+ async function documentsVault(client, businessId) {
1325
+ const passphrase = process.env.COINPAY_DOCUMENTS_PASSPHRASE || (await promptPassword('Documents passphrase: '));
1326
+ return unlockDocumentsVault(client, { businessId, passphrase });
1327
+ }
1328
+
1329
+ async function handleDocument(subcommand, args, flags) {
1330
+ const client = createClient();
1331
+ requireFlagValues(flags, ['business-id', 'title', 'message', 'signers', 'viewers', 'editors', 'reason', 'out', 'note', 'revision']);
1332
+ switch (subcommand) {
1333
+ case 'vault-setup': {
1334
+ if (!flags['business-id']) usageExit('coinpay document vault-setup --business-id <id>');
1335
+ print.warn('If this passphrase is lost, nobody (including CoinPay) can recover the documents.');
1336
+ const passphrase = process.env.COINPAY_DOCUMENTS_PASSPHRASE || (await promptPassword('New documents passphrase: '));
1337
+ if (!process.env.COINPAY_DOCUMENTS_PASSPHRASE && (await promptPassword('Repeat it: ')) !== passphrase) usageExit('The passphrases do not match');
1338
+ const vault = await setupDocumentsVault(client, { businessId: flags['business-id'], passphrase });
1339
+ return flags.json ? print.json({ version: vault.version }) : print.success('Documents vault created (version ' + vault.version + ')');
1340
+ }
1341
+ case 'list': {
1342
+ const rows = await listDocuments(client, { businessId: flags['business-id'] });
1343
+ if (flags.json) return print.json(rows);
1344
+ if (!rows.length) return print.info('No documents');
1345
+ for (const d of rows) {
1346
+ const progress = d.signers ? ' ' + d.signed + '/' + d.signers + ' signed' : '';
1347
+ print.info(d.id + ' ' + d.title + ' ' + d.status + progress);
1348
+ }
1349
+ return;
1350
+ }
1351
+ case 'upload': {
1352
+ if (args.length !== 1 || !flags['business-id']) usageExit('coinpay document upload <file> --business-id <id> [--title "..."] [--message "..."]');
1353
+ const vault = await documentsVault(client, flags['business-id']);
1354
+ const doc = await uploadDocument(client, {
1355
+ businessId: flags['business-id'],
1356
+ vault,
1357
+ bytes: readFileSync(args[0]),
1358
+ filename: args[0],
1359
+ title: flags.title,
1360
+ message: flags.message,
1361
+ });
1362
+ if (flags.json) return print.json(doc);
1363
+ print.success('Uploaded (encrypted): ' + doc.id);
1364
+ return print.info('Send it with: coinpay document share ' + doc.id + ' --signers "Name <email>"');
1365
+ }
1366
+ case 'share': {
1367
+ const recipients = [...parsePeople(flags.signers, 'signer'), ...parsePeople(flags.editors, 'editor'), ...parsePeople(flags.viewers, 'viewer')];
1368
+ if (args.length !== 1 || !recipients.length) usageExit('coinpay document share <id> --signers "Ada <ada@x.com>" [--editors "..."] [--viewers "..."] [--message] [--no-email] [--email-key]');
1369
+ const { document } = await getDocument(client, args[0]);
1370
+ const vault = await documentsVault(client, document.business_id);
1371
+ const result = await shareDocument(client, args[0], {
1372
+ recipients,
1373
+ message: flags.message,
1374
+ sendEmail: !flags['no-email'],
1375
+ emailKey: !!flags['email-key'],
1376
+ vault,
1377
+ });
1378
+ if (flags.json) return print.json(result);
1379
+ print.success('Shared; document is now ' + result.status);
1380
+ if (!flags['email-key']) print.warn('The emails do NOT contain the key. Send each person their full link below on another channel:');
1381
+ for (const r of result.recipients) print.info(r.role.padEnd(6) + ' ' + r.email + ' ' + r.link);
1382
+ return;
1383
+ }
1384
+ case 'get': {
1385
+ if (args.length !== 1) usageExit('coinpay document get <id>');
1386
+ const data = await getDocument(client, args[0]);
1387
+ if (flags.json) return print.json(data);
1388
+ const d = data.document;
1389
+ print.info(d.title + ' ' + d.status + ' ' + d.filename + ' (' + d.bytes + ' bytes)');
1390
+ print.info('sha256 ' + d.sha256 + (d.signed_sha256 ? '\nsigned sha256 ' + d.signed_sha256 : ''));
1391
+ for (const r of data.recipients) {
1392
+ if (r.revoked_at) continue;
1393
+ print.info(' ' + r.id + ' ' + r.role + ' ' + r.name + ' <' + r.email + '> ' + r.status + (r.signed_at ? ' ' + r.signed_at : ''));
1394
+ }
1395
+ for (const e of data.events) print.info(' ' + e.created_at + ' ' + e.event + (e.actor_label ? ' ' + e.actor_label : '') + (e.ip ? ' ' + e.ip : ''));
1396
+ return;
1397
+ }
1398
+ case 'link': {
1399
+ if (args.length !== 2) usageExit('coinpay document link <id> <recipient-id> [--no-email] [--email-key]');
1400
+ const { document } = await getDocument(client, args[0]);
1401
+ const vault = await documentsVault(client, document.business_id);
1402
+ const result = await newDocumentLink(client, args[0], args[1], { sendEmail: !flags['no-email'], emailKey: !!flags['email-key'], vault });
1403
+ return flags.json ? print.json(result) : print.info(result.link);
1404
+ }
1405
+ case 'revoke': {
1406
+ if (args.length !== 2) usageExit('coinpay document revoke <id> <recipient-id>');
1407
+ const result = await revokeDocumentRecipient(client, args[0], args[1]);
1408
+ return flags.json ? print.json(result) : print.success('Revoked; document is now ' + result.status);
1409
+ }
1410
+ case 'versions': {
1411
+ if (args.length !== 1) usageExit('coinpay document versions <id>');
1412
+ const rows = await listDocumentVersions(client, args[0]);
1413
+ if (flags.json) return print.json(rows);
1414
+ for (const v of rows) print.info('v' + v.version + ' ' + v.created_at + ' ' + v.filename + ' ' + v.bytes + ' bytes' + (v.uploaded_by ? ' ' + v.uploaded_by : '') + (v.note ? ' ' + v.note : ''));
1415
+ return;
1416
+ }
1417
+ case 'new-version': {
1418
+ if (args.length !== 2) usageExit('coinpay document new-version <id> <file> [--note "what changed"]');
1419
+ const { document } = await getDocument(client, args[0]);
1420
+ const vault = await documentsVault(client, document.business_id);
1421
+ const result = await uploadDocumentVersion(client, args[0], { vault, bytes: readFileSync(args[1]), filename: args[1], note: flags.note });
1422
+ return flags.json ? print.json(result) : print.success('Uploaded version ' + result.version + ' (encrypted)');
1423
+ }
1424
+ case 'download': {
1425
+ if (args.length !== 1) usageExit('coinpay document download <id> [--signed | --revision N] [--out path]');
1426
+ const { document } = await getDocument(client, args[0]);
1427
+ const vault = await documentsVault(client, document.business_id);
1428
+ const file = await downloadDocument(client, args[0], {
1429
+ version: flags.signed ? 'signed' : 'original',
1430
+ revision: flags.revision ? Number(flags.revision) : undefined,
1431
+ vault,
1432
+ });
1433
+ const out = flags.out || file.filename;
1434
+ writeFileSync(out, file.bytes, { mode: 0o600 });
1435
+ return print.success('Decrypted ' + file.bytes.length + ' bytes to ' + out + ' (sha256 verified)');
1436
+ }
1437
+ case 'void': {
1438
+ if (args.length !== 1) usageExit('coinpay document void <id> [--reason "..."]');
1439
+ await voidDocument(client, args[0], { reason: flags.reason });
1440
+ return print.success('Voided');
1441
+ }
1442
+ case 'delete': {
1443
+ if (args.length !== 1) usageExit('coinpay document delete <id>');
1444
+ await deleteDocument(client, args[0]);
1445
+ return print.success('Deleted');
1446
+ }
1447
+ default:
1448
+ usageExit('coinpay document <vault-setup|list|upload|share|get|versions|new-version|link|revoke|download|void|delete>');
1449
+ }
1450
+ }
1451
+
1280
1452
  async function handleContractor(subcommand, args, flags) {
1281
1453
  const client = createClient();
1282
1454
  requireFlagValues(flags, ['business-id', 'name', 'email', 'country', 'title', 'kind', 'rate', 'frequency', 'start', 'end', 'note', 'signature', 'reason', 'amount', 'contract', 'description', 'status', 'year']);
@@ -5257,6 +5429,11 @@ async function handleLightning(subcommand, args, flags) {
5257
5429
  await handleProject(subcommand, args, flags);
5258
5430
  break;
5259
5431
 
5432
+ case 'document':
5433
+ case 'documents':
5434
+ await handleDocument(subcommand, args, flags);
5435
+ break;
5436
+
5260
5437
  case 'contractor':
5261
5438
  case 'contractors':
5262
5439
  await handleContractor(subcommand, args, flags);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.19.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
+ }
@@ -891,10 +891,21 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
891
891
  const action = flags.off ? 'off' : args[0] || 'show';
892
892
  const describe = (s) => s
893
893
  ? `Daily summary ${s.active ? 'on' : 'off'} · ${String(s.hour).padStart(2, '0')}:00 ${s.timezone} · to ${s.recipients.join(', ')}${s.nextRunAt ? ` · next ${s.nextRunAt}` : ''}${s.lastSentAt ? ` · last ${s.lastSentAt}` : ''}`
894
- : 'No daily summary configured. Turn it on with: coinpay finances daily on --hour 7 --to you@example.com';
894
+ : 'No daily summary configured. Turn it on with: coinpay finances daily on --hour 7';
895
+ const targetText = (t) => (t === null || t === undefined ? 'no daily spend target' : `target $${t}/day`);
896
+ if (flags.target !== undefined) {
897
+ const raw = String(flags.target);
898
+ const target = ['off', 'clear', 'none'].includes(raw) ? null : Number(raw.replace(/^\$/, ''));
899
+ if (target !== null && (!Number.isFinite(target) || target < 0)) throw new CliExit(EXIT.INVALID, 'The target is a daily amount, e.g. --target 100, or --target clear');
900
+ const data = await api.setDailySpendTarget(client, target);
901
+ if (action === 'show') {
902
+ emit({ schedule: data.schedule, target: data.target }, `${describe(data.schedule)}\n${target === null ? 'Cleared the daily spend target.' : `Daily spend target set to $${data.target}/day.`}`);
903
+ return EXIT.OK;
904
+ }
905
+ }
895
906
  if (action === 'show') {
896
- const s = await api.getDailySummary(client);
897
- emit({ schedule: s }, describe(s));
907
+ const { schedule: s, target } = await api.getDailySummaryWithTarget(client);
908
+ emit({ schedule: s, target }, `${describe(s)} · ${targetText(target)}`);
898
909
  return EXIT.OK;
899
910
  }
900
911
  if (action === 'on' || action === 'set') {
@@ -917,7 +928,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
917
928
  emit(data, `Sent to ${data.sent.join(', ') || 'nobody'} (${data.due} due, ${data.overdue} overdue)${data.failed.length ? `; failed: ${data.failed.map((f) => `${f.to} (${f.error})`).join(', ')}` : ''}`);
918
929
  return data.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
919
930
  }
920
- throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances daily [show|on --hour 7 --to a@x|off|send-now]');
931
+ throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances daily [show|on --hour 7 --to a@x|off|send-now] [--target 100|--target clear]');
921
932
  }
922
933
 
923
934
  case 'payloads': {
@@ -461,6 +461,12 @@ export async function getDailySummary(client) {
461
461
  return data.schedule;
462
462
  }
463
463
 
464
+ /** The daily summary schedule plus the owner's $/day spend target (null when none). */
465
+ export async function getDailySummaryWithTarget(client) {
466
+ const data = await call(client, '/finances/email-schedules?kind=daily_summary');
467
+ return { schedule: data.schedule, target: data.dailySpendTarget ?? null };
468
+ }
469
+
464
470
  export async function setDailySummary(client, { weekdays, hour, timezone, recipients, active } = {}) {
465
471
  const data = await call(client, '/finances/email-schedules', {
466
472
  method: 'POST',
@@ -469,6 +475,15 @@ export async function setDailySummary(client, { weekdays, hour, timezone, recipi
469
475
  return data.schedule;
470
476
  }
471
477
 
478
+ /** Set the daily summary's $/day spend target, or clear it with null. Does not turn the summary on. */
479
+ export async function setDailySpendTarget(client, target) {
480
+ const data = await call(client, '/finances/email-schedules', {
481
+ method: 'POST',
482
+ body: JSON.stringify({ kind: 'daily_summary', target }),
483
+ });
484
+ return { schedule: data.schedule, target: data.dailySpendTarget ?? null };
485
+ }
486
+
472
487
  export async function deleteDailySummary(client) {
473
488
  return call(client, '/finances/email-schedules?kind=daily_summary', { method: 'DELETE' });
474
489
  }
package/src/index.d.ts CHANGED
@@ -114,6 +114,23 @@ export {
114
114
  get1099Summary,
115
115
  } from './contractors.js';
116
116
  export type { Contractor, ContractorContract, ContractorMilestone, ContractorWorkItem, PayRun, PayRunItem, PayOutcome } from './contractors.js';
117
+ export {
118
+ listDocuments,
119
+ getDocument,
120
+ listDocumentVaults,
121
+ setupDocumentsVault,
122
+ unlockDocumentsVault,
123
+ uploadDocument,
124
+ shareDocument,
125
+ newDocumentLink,
126
+ revokeDocumentRecipient,
127
+ voidDocument,
128
+ deleteDocument,
129
+ downloadDocument,
130
+ listDocumentVersions,
131
+ uploadDocumentVersion,
132
+ } from './documents.js';
133
+ export type { CoinPayDocument, DocumentRecipient, DocumentVault, DocumentVersion, UnlockedVault } from './documents.js';
117
134
 
118
135
  // Webhook exports
119
136
  export {
package/src/index.js CHANGED
@@ -128,6 +128,23 @@ import {
128
128
  get1099Summary,
129
129
  } from './contractors.js';
130
130
 
131
+ import {
132
+ listDocuments,
133
+ getDocument,
134
+ listDocumentVaults,
135
+ setupDocumentsVault,
136
+ unlockDocumentsVault,
137
+ uploadDocument,
138
+ shareDocument,
139
+ newDocumentLink,
140
+ revokeDocumentRecipient,
141
+ voidDocument,
142
+ deleteDocument,
143
+ downloadDocument,
144
+ listDocumentVersions,
145
+ uploadDocumentVersion,
146
+ } from './documents.js';
147
+
131
148
  // Wallet exports
132
149
  import {
133
150
  WalletClient,
@@ -312,6 +329,20 @@ export {
312
329
  payPayRun,
313
330
  recordPayRunPayments,
314
331
  get1099Summary,
332
+ listDocuments,
333
+ getDocument,
334
+ listDocumentVaults,
335
+ setupDocumentsVault,
336
+ unlockDocumentsVault,
337
+ uploadDocument,
338
+ shareDocument,
339
+ newDocumentLink,
340
+ revokeDocumentRecipient,
341
+ voidDocument,
342
+ deleteDocument,
343
+ downloadDocument,
344
+ listDocumentVersions,
345
+ uploadDocumentVersion,
315
346
 
316
347
  // Wallet
317
348
  WalletClient,
package/src/mcp.js CHANGED
@@ -18,6 +18,7 @@ import * as sf from './statements-fetch.js';
18
18
  import { listInvoices, getInvoice, createInvoice } from './invoices.js';
19
19
  import * as projects from './projects.js';
20
20
  import * as contractors from './contractors.js';
21
+ import * as documents from './documents.js';
21
22
 
22
23
  export const PROTOCOL_VERSION = '2025-06-18';
23
24
 
@@ -95,13 +96,14 @@ export const TOOLS = [
95
96
  },
96
97
  {
97
98
  name: 'finance_daily_summary',
98
- description: 'The daily summary email (due soon, new activity, position): show it, turn it on (optionally hour 0-23 and recipients), turn it off, or send one now.',
99
+ description: 'The daily summary email (spend vs a $/day target, top cuts, due soon, new activity, position): show it, turn it on (optionally hour 0-23 and recipients), turn it off, send one now, or set_target (target in $/day, null clears).',
99
100
  inputSchema: {
100
101
  type: 'object',
101
102
  properties: {
102
- action: { type: 'string', enum: ['show', 'on', 'off', 'send_now'] },
103
+ action: { type: 'string', enum: ['show', 'on', 'off', 'send_now', 'set_target'] },
103
104
  hour: { type: 'integer', minimum: 0, maximum: 23 },
104
105
  to: { type: 'array', items: { type: 'string' }, maxItems: 5 },
106
+ target: { type: ['number', 'null'], minimum: 0 },
105
107
  },
106
108
  required: ['action'],
107
109
  additionalProperties: false,
@@ -110,7 +112,8 @@ export const TOOLS = [
110
112
  if (args.action === 'on') return text({ schedule: await reports.setDailySummary(client, { hour: args.hour, recipients: args.to, active: true }) });
111
113
  if (args.action === 'off') return text(await reports.deleteDailySummary(client));
112
114
  if (args.action === 'send_now') return text(await reports.sendDailySummaryNow(client));
113
- return text({ schedule: await reports.getDailySummary(client) });
115
+ if (args.action === 'set_target') return text(await reports.setDailySpendTarget(client, args.target ?? null));
116
+ return text(await reports.getDailySummaryWithTarget(client));
114
117
  },
115
118
  },
116
119
  {
@@ -384,6 +387,31 @@ export const TOOLS = [
384
387
  inputSchema: { type: 'object', properties: { businessId: { type: 'string' }, year: { type: 'integer' } }, additionalProperties: false },
385
388
  handler: async (args, { client }) => text(await contractors.get1099Summary(client, args)),
386
389
  },
390
+ // Documents are end-to-end encrypted. These tools see only metadata and the
391
+ // audit trail; opening, uploading or sharing a file needs the passphrase,
392
+ // which stays with a person (dashboard or `coinpay document`), never an agent.
393
+ {
394
+ name: 'document_list',
395
+ description: 'List documents with status (draft, shared, out_for_signature, completed, declined, voided) and signing progress. Contents are end-to-end encrypted and not available here.',
396
+ inputSchema: { type: 'object', properties: { businessId: { type: 'string' }, status: { type: 'string' } }, additionalProperties: false },
397
+ handler: async (args, { client }) => text({ documents: await documents.listDocuments(client, args) }),
398
+ },
399
+ {
400
+ name: 'document_get',
401
+ description: 'One document: who it was sent to, who signed (name, time, IP), and the full audit trail. No file contents or keys.',
402
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false },
403
+ handler: async (args, { client }) => {
404
+ const { document, recipients, events } = await documents.getDocument(client, args.id);
405
+ const { wrapped_key: _k, ...meta } = document;
406
+ return text({ document: meta, recipients, events });
407
+ },
408
+ },
409
+ {
410
+ name: 'document_void',
411
+ description: 'Void a document that has not been completed: links stop working and nobody can sign it. Cannot be undone.',
412
+ inputSchema: { type: 'object', properties: { id: { type: 'string' }, reason: { type: 'string' } }, required: ['id'], additionalProperties: false },
413
+ handler: async (args, { client }) => text(await documents.voidDocument(client, args.id, { reason: args.reason })),
414
+ },
387
415
  ];
388
416
 
389
417
  /** Answer one JSON-RPC message; null for a notification. */