@jipsoft/factusync-cli 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -30,11 +30,12 @@ Requires Node.js 22 or newer.
30
30
  | Variable | Required | Meaning |
31
31
  |---|---|---|
32
32
  | `FACTUSYNC_API_KEY` | yes | A FactuSync API key. Sent as the `X-API-Key` header. |
33
- | `FACTUSYNC_MCP_URL` | no | The MCP endpoint. Default `https://factusync-api.jipsoft.com/mcp`. |
33
+ | `FACTUSYNC_MCP_URL` | no | The MCP endpoint. Default `https://factusync-api.jipsoft.com/mcp`. Must be `https:`; `http:` is accepted only for a loopback host (`localhost`, `127.x.x.x`, `[::1]`). |
34
34
 
35
35
  The key is **never** accepted as a flag (`--api-key`, `--key` are rejected as unknown options), so it
36
36
  does not end up in shell history or in the process list. Never print the key or paste it into a
37
- conversation.
37
+ conversation. Redirects are never followed on a request that carries the key (`REDIRECT_REFUSED`,
38
+ `RIDE_REDIRECT_REFUSED`): set `FACTUSYNC_MCP_URL` to the final URL of the endpoint.
38
39
 
39
40
  A key's **SRI environment** (testing or production) is fixed when the key is created: every document
40
41
  created through it goes to that environment. `factusync context` tells you which one it is.
@@ -162,17 +163,23 @@ taxableBase, taxAmount }], totals, payments }`.
162
163
  Only after the user approved the draft. Prints `{ documentId, jobId, status, next }`; then follow the
163
164
  document with `documents get`. Only `DRAFT` documents can be emitted.
164
165
 
165
- ### `factusync ride <id> [--out file.pdf] [--xml file.xml]`
166
+ ### `factusync ride <id> [--out file.pdf|dir/] [--xml file.xml|dir/]`
166
167
 
167
168
  Only for `AUTHORIZED` documents. Prints `{ documentId, status, number, accessKey, rideUrl, rideDownload,
168
169
  xml?, xmlTruncated? }`.
169
170
 
170
171
  - `--out file.pdf` downloads the PDF from `rideUrl` with the same key and adds `rideFile` to the output.
171
172
  The key is only sent when `rideUrl` is on the same origin as `FACTUSYNC_MCP_URL`
172
- (otherwise `RIDE_URL_UNTRUSTED`).
173
+ (otherwise `RIDE_URL_UNTRUSTED`). The response must be `application/pdf` (otherwise `RIDE_NOT_PDF`)
174
+ and at most 20 MiB (otherwise `RIDE_TOO_LARGE`, checked while it streams).
173
175
  - `--xml file.xml` writes the authorized XML, adds `xmlFile` and leaves `xml` out of the output. The
174
176
  XML is only returned when the key also has `documents:read` (otherwise `XML_NOT_AVAILABLE`); an XML
175
177
  the server truncated is not written (`XML_TRUNCATED`).
178
+ - `--out` and `--xml` take a file or a directory. A file path is written exactly as given, replacing
179
+ what is there. A directory (one that exists, or any path ending in `/`) gets a generated name,
180
+ `ride-<accessKey>-<UTC time>.pdf` / `.xml` (for example `ride-2409…811-20261007T153012Z.pdf`; the
181
+ `documentId` when there is no access key), created only if it does not exist yet: an existing
182
+ file is never replaced (`OUTPUT_FILE_EXISTS`). `rideFile` / `xmlFile` give the path written.
176
183
  - If any check fails, no file is written.
177
184
 
178
185
  ### `factusync tools`
@@ -191,9 +198,9 @@ command for each.
191
198
  | Exit | Meaning | Codes |
192
199
  |---|---|---|
193
200
  | 0 | OK | |
194
- | 1 | The tool answered with an error | the API code, `TOOL_ERROR`, `TOOL_NOT_AVAILABLE`, `INVALID_INPUT`, `MCP_ERROR`, `XML_NOT_AVAILABLE`, `XML_TRUNCATED`, `RIDE_URL_UNTRUSTED`, `RIDE_DOWNLOAD_FAILED` |
195
- | 2 | Usage error: nothing was sent | `USAGE`, `CONFIRMATION_REQUIRED`, `INVALID_INPUT_FILE`, `OUTPUT_FILE_NOT_WRITABLE` |
196
- | 3 | Authentication or network | `MISSING_API_KEY`, `UNAUTHORIZED`, `FORBIDDEN`, `HTTP_ERROR`, `NETWORK_ERROR`, `TIMEOUT` |
201
+ | 1 | The tool answered with an error | the API code, `TOOL_ERROR`, `TOOL_NOT_AVAILABLE`, `INVALID_INPUT`, `MCP_ERROR`, `XML_NOT_AVAILABLE`, `XML_TRUNCATED`, `RIDE_URL_UNTRUSTED`, `RIDE_DOWNLOAD_FAILED`, `RIDE_NOT_PDF`, `RIDE_TOO_LARGE` |
202
+ | 2 | Usage error: nothing was sent | `USAGE`, `CONFIRMATION_REQUIRED`, `INVALID_INPUT_FILE`, `OUTPUT_FILE_NOT_WRITABLE`, `OUTPUT_FILE_EXISTS` |
203
+ | 3 | Authentication or network | `MISSING_API_KEY`, `UNAUTHORIZED`, `FORBIDDEN`, `HTTP_ERROR`, `NETWORK_ERROR`, `TIMEOUT`, `REDIRECT_REFUSED`, `RIDE_REDIRECT_REFUSED` |
197
204
 
198
205
  A missing `FACTUSYNC_API_KEY` fails with exit 3 before any network request.
199
206
 
@@ -210,6 +217,19 @@ factusync documents get "$DOCUMENT_ID" # until AUTHORIZED or REJECTED
210
217
  factusync ride "$DOCUMENT_ID" --out invoice.pdf --xml invoice.xml
211
218
  ```
212
219
 
220
+ ## Security
221
+
222
+ What the CLI guarantees about the API key and the files it writes:
223
+
224
+ - The key is read from the environment only, sent as `X-API-Key` only to `FACTUSYNC_MCP_URL`'s origin,
225
+ and never over plain `http:` except to a loopback host.
226
+ - No request that carries the key follows a redirect (`REDIRECT_REFUSED`, `RIDE_REDIRECT_REFUSED`).
227
+ - A RIDE download must be `application/pdf` and at most 20 MiB; a directory `--out`/`--xml` never
228
+ replaces an existing file.
229
+
230
+ 0.1.3 added the https-only rule, the redirect refusal, the RIDE checks and the directory naming, and moved to `@modelcontextprotocol/sdk` 1.32.1, after an external
231
+ review of 0.1.2 ([issue #1](https://github.com/jipsoft-labs/factusync-cli/issues/1)).
232
+
213
233
  ## Source and issues
214
234
 
215
235
  This package is developed inside JipSoft's private monorepo, next to the FactuSync server it talks
package/dist/commands.js CHANGED
@@ -84,12 +84,18 @@ export const COMMANDS = [
84
84
  {
85
85
  words: ['ride'],
86
86
  tool: 'factusync_get_ride',
87
- usage: 'factusync ride <id> [--out <file.pdf>] [--xml <file.xml>]',
87
+ usage: 'factusync ride <id> [--out <file.pdf|dir/>] [--xml <file.xml|dir/>]',
88
88
  summary: 'For an AUTHORIZED document: the RIDE (PDF) URL, and the authorized XML when the key may read documents.',
89
89
  positionals: [{ name: 'id', argument: 'id' }],
90
90
  flags: {
91
- out: { type: 'string', description: 'Download the RIDE PDF to this file (sent with the same API key)' },
92
- xml: { type: 'string', description: 'Write the authorized XML to this file (needs documents:read too)' },
91
+ out: {
92
+ type: 'string',
93
+ description: 'Download the RIDE PDF to this file, replacing it, or into this directory under a new timestamped name that never replaces a file (sent with the same API key)',
94
+ },
95
+ xml: {
96
+ type: 'string',
97
+ description: 'Write the authorized XML to this file, or into this directory under a new timestamped name (needs documents:read too)',
98
+ },
93
99
  },
94
100
  },
95
101
  {
package/dist/config.js CHANGED
@@ -1,5 +1,12 @@
1
1
  import { CliFailure, EXIT_AUTH_OR_NETWORK, usageFailure } from './failure.js';
2
2
  export const DEFAULT_MCP_URL = 'https://factusync-api.jipsoft.com/mcp';
3
+ /**
4
+ * Plain http would carry the API key in clear text: it is accepted only for a server on this
5
+ * machine (a local FactuSync, tests). `URL` already normalizes the host, so `127.1` is `127.0.0.1`.
6
+ */
7
+ function isLoopback(hostname) {
8
+ return hostname === 'localhost' || hostname === '[::1]' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
9
+ }
3
10
  /**
4
11
  * Configuration comes from the environment only. The key in particular is never a flag: a flag
5
12
  * lands in shell history and in the process list, where other users on the machine can read it.
@@ -13,8 +20,8 @@ export function readConfig(env) {
13
20
  catch {
14
21
  throw usageFailure(`FACTUSYNC_MCP_URL is not a valid URL: ${rawUrl}`);
15
22
  }
16
- if (mcpUrl.protocol !== 'https:' && mcpUrl.protocol !== 'http:') {
17
- throw usageFailure(`FACTUSYNC_MCP_URL must be an http(s) URL: ${rawUrl}`);
23
+ if (mcpUrl.protocol !== 'https:' && !(mcpUrl.protocol === 'http:' && isLoopback(mcpUrl.hostname))) {
24
+ throw usageFailure(`FACTUSYNC_MCP_URL must be an https URL (http only for localhost): ${rawUrl}`);
18
25
  }
19
26
  const apiKey = env['FACTUSYNC_API_KEY']?.trim();
20
27
  if (!apiKey) {
package/dist/help.js CHANGED
@@ -3,7 +3,7 @@ import { DEFAULT_MCP_URL } from './config.js';
3
3
  const COMMON = [
4
4
  'Environment:',
5
5
  ' FACTUSYNC_API_KEY Required. Your FactuSync API key. Never accepted as a flag.',
6
- ` FACTUSYNC_MCP_URL Optional. Default ${DEFAULT_MCP_URL}`,
6
+ ` FACTUSYNC_MCP_URL Optional. Default ${DEFAULT_MCP_URL}. https only (http for localhost).`,
7
7
  '',
8
8
  'Output: the tool result as JSON on stdout (--pretty indents it).',
9
9
  'Errors: JSON {code, message, details?} on stderr.',
@@ -12,7 +12,7 @@ import { CLI_VERSION } from './version.js';
12
12
  export async function withMcpSession(config, action) {
13
13
  const transport = new StreamableHTTPClientTransport(config.mcpUrl, {
14
14
  requestInit: { headers: { 'X-API-Key': config.apiKey } },
15
- fetch: config.fetch,
15
+ fetch: refusingRedirects(config.fetch),
16
16
  });
17
17
  const client = new Client({ name: 'factusync-cli', version: CLI_VERSION });
18
18
  const options = config.timeoutMs !== undefined ? { timeout: config.timeoutMs } : undefined;
@@ -50,6 +50,15 @@ export async function withMcpSession(config, action) {
50
50
  await client.close().catch(() => undefined);
51
51
  }
52
52
  }
53
+ /**
54
+ * Every request here carries `X-API-Key`, and on a cross-origin redirect `fetch` strips
55
+ * `Authorization` but forwards every other header: following one would hand the key to whatever
56
+ * host the `Location` names. FactuSync's `POST /mcp` never redirects, so none is ever followed,
57
+ * whatever `redirect` the SDK passes.
58
+ */
59
+ function refusingRedirects(fetchFn) {
60
+ return (input, init) => fetchFn(input, { ...init, redirect: 'error' });
61
+ }
53
62
  /** A successful result without `structuredContent`: its first text block, as JSON when it is JSON. */
54
63
  function parseTextContent(result) {
55
64
  const text = textBlocks(result)[0] ?? '';
@@ -131,10 +140,21 @@ export function transportFailure(error) {
131
140
  }
132
141
  return networkFailure(error);
133
142
  }
134
- /** `fetch` rejections: refused, unresolvable, reset or aborted connections. */
143
+ /**
144
+ * `fetch` with `redirect: 'error'` rejecting on a 3xx. Undici reports it only as a `TypeError`
145
+ * whose cause says so; nothing more structured exists to test against.
146
+ */
147
+ export function isRefusedRedirect(error) {
148
+ const cause = error?.cause;
149
+ return error instanceof TypeError && cause instanceof Error && /redirect/i.test(cause.message);
150
+ }
151
+ /** `fetch` rejections: refused, unresolvable, reset or aborted connections, refused redirects. */
135
152
  export function networkFailure(error) {
136
153
  if (error instanceof CliFailure)
137
154
  return error;
155
+ if (isRefusedRedirect(error)) {
156
+ return new CliFailure(EXIT_AUTH_OR_NETWORK, 'REDIRECT_REFUSED', 'FactuSync answered with a redirect, which is never followed: the API key goes only to FACTUSYNC_MCP_URL. Check that it is the final URL of the MCP endpoint.');
157
+ }
138
158
  if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
139
159
  return new CliFailure(EXIT_AUTH_OR_NETWORK, 'TIMEOUT', 'FactuSync did not answer in time.');
140
160
  }
package/dist/run.js CHANGED
@@ -1,13 +1,16 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
1
+ import { readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { join, sep } from 'node:path';
2
3
  import { parseArgs } from 'node:util';
3
4
  import { commandForTool, findCommand } from './commands.js';
4
5
  import { readConfig } from './config.js';
5
6
  import { CliFailure, EXIT_AUTH_OR_NETWORK, EXIT_OK, EXIT_TOOL_ERROR, EXIT_USAGE, usageFailure } from './failure.js';
6
7
  import { commandHelp, rootHelp } from './help.js';
7
- import { networkFailure, withMcpSession } from './mcp-session.js';
8
+ import { isRefusedRedirect, networkFailure, withMcpSession } from './mcp-session.js';
8
9
  import { CLI_VERSION } from './version.js';
9
10
  /** Same default as the SDK's MCP requests, for the RIDE download. */
10
11
  const DEFAULT_TIMEOUT_MS = 60_000;
12
+ /** A RIDE is a one- or two-page PDF of tens of KiB: anything near this is not one. */
13
+ export const MAX_RIDE_BYTES = 20 * 1024 * 1024;
11
14
  /** Runs one CLI invocation and returns its exit code. Never throws. */
12
15
  export async function run(argv, env, io) {
13
16
  try {
@@ -164,8 +167,9 @@ async function listTools(session) {
164
167
  }
165
168
  /**
166
169
  * `ride --out/--xml`: every check runs before anything is written, so a failure leaves no file.
167
- * The PDF is fetched with the key only from the MCP endpoint's own origin: the key never goes
168
- * to a host the user did not configure, whatever URL a response carries.
170
+ * The PDF is fetched with the key only from the MCP endpoint's own origin, and a redirect is
171
+ * refused rather than followed: the key never goes to a host the user did not configure, whatever
172
+ * URL a response or its `Location` carries.
169
173
  */
170
174
  async function saveRide(result, parsed, config, io) {
171
175
  const out = parsed.values['out'];
@@ -179,25 +183,55 @@ async function saveRide(result, parsed, config, io) {
179
183
  throw new CliFailure(EXIT_TOOL_ERROR, 'XML_TRUNCATED', 'The server truncated this XML; it was not written. Download it from the FactuSync API instead.');
180
184
  }
181
185
  }
186
+ const stem = outputStem(result, (io.now ?? (() => new Date()))());
187
+ const pdfTarget = typeof out === 'string' ? await outputTarget(out, stem, 'pdf') : undefined;
188
+ const xmlTarget = typeof xmlPath === 'string' ? await outputTarget(xmlPath, stem, 'xml') : undefined;
182
189
  let pdf;
183
- if (typeof out === 'string')
190
+ if (pdfTarget)
184
191
  pdf = await downloadRide(result['rideUrl'], config, io);
185
192
  const saved = { ...result };
193
+ if (pdf && pdfTarget) {
194
+ await writeOutput(pdfTarget, pdf);
195
+ saved['rideFile'] = pdfTarget.path;
196
+ }
197
+ if (xmlTarget) {
198
+ await writeOutput(xmlTarget, result['xml']);
199
+ delete saved['xml'];
200
+ saved['xmlFile'] = xmlTarget.path;
201
+ }
202
+ return saved;
203
+ }
204
+ /**
205
+ * `--out`/`--xml` naming a directory (an existing one, or any path ending in a separator) get a
206
+ * generated file name inside it, so repeated downloads never replace each other. A file path is
207
+ * the integrator's exact choice and is written as given.
208
+ */
209
+ async function outputTarget(value, stem, extension) {
210
+ const isDirectory = value.endsWith('/') ||
211
+ value.endsWith(sep) ||
212
+ (await stat(value).then((stats) => stats.isDirectory(), () => false));
213
+ return isDirectory ? { path: join(value, `${stem}.${extension}`), exclusive: true } : { path: value, exclusive: false };
214
+ }
215
+ /**
216
+ * `ride-<accessKey or documentId>-<UTC time>`, e.g. `ride-2409…811-20261007T153012Z`. Both come
217
+ * from the server, so anything outside `[A-Za-z0-9._-]` becomes `_`: no separator can climb out of
218
+ * the directory the user chose.
219
+ */
220
+ function outputStem(result, now) {
221
+ const id = [result['accessKey'], result['documentId']].find((value) => typeof value === 'string' && value !== '');
222
+ const timestamp = now.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '');
223
+ return `ride-${(id ?? 'document').replace(/[^A-Za-z0-9._-]/g, '_')}-${timestamp}`;
224
+ }
225
+ async function writeOutput(target, data) {
186
226
  try {
187
- if (pdf && typeof out === 'string') {
188
- await writeFile(out, pdf);
189
- saved['rideFile'] = out;
190
- }
191
- if (typeof xmlPath === 'string') {
192
- await writeFile(xmlPath, result['xml'], 'utf8');
193
- delete saved['xml'];
194
- saved['xmlFile'] = xmlPath;
195
- }
227
+ await writeFile(target.path, data, target.exclusive ? { flag: 'wx' } : {});
196
228
  }
197
229
  catch (error) {
230
+ if (target.exclusive && error.code === 'EEXIST') {
231
+ throw new CliFailure(EXIT_USAGE, 'OUTPUT_FILE_EXISTS', `${target.path} already exists; it was not replaced. Run it again in a moment.`);
232
+ }
198
233
  throw new CliFailure(EXIT_USAGE, 'OUTPUT_FILE_NOT_WRITABLE', `Cannot write the output file: ${error.message}`);
199
234
  }
200
- return saved;
201
235
  }
202
236
  async function downloadRide(rideUrl, config, io) {
203
237
  let url;
@@ -212,12 +246,17 @@ async function downloadRide(rideUrl, config, io) {
212
246
  }
213
247
  let response;
214
248
  try {
249
+ // The API serves the PDF itself and never redirects; following one would forward X-API-Key.
215
250
  response = await io.fetch(url, {
216
251
  headers: { 'X-API-Key': config.apiKey },
252
+ redirect: 'error',
217
253
  signal: AbortSignal.timeout(io.timeoutMs ?? DEFAULT_TIMEOUT_MS),
218
254
  });
219
255
  }
220
256
  catch (error) {
257
+ if (isRefusedRedirect(error)) {
258
+ throw new CliFailure(EXIT_AUTH_OR_NETWORK, 'RIDE_REDIRECT_REFUSED', 'The RIDE download answered with a redirect, which is never followed: the API key is not sent to another URL.');
259
+ }
221
260
  throw networkFailure(error);
222
261
  }
223
262
  if (response.status === 401 || response.status === 403) {
@@ -228,5 +267,53 @@ async function downloadRide(rideUrl, config, io) {
228
267
  status: response.status,
229
268
  });
230
269
  }
231
- return new Uint8Array(await response.arrayBuffer());
270
+ const contentType = response.headers.get('content-type') ?? '';
271
+ if (contentType.split(';')[0].trim().toLowerCase() !== 'application/pdf') {
272
+ await response.body?.cancel().catch(() => undefined);
273
+ throw new CliFailure(EXIT_TOOL_ERROR, 'RIDE_NOT_PDF', `The RIDE download is not a PDF (Content-Type '${contentType}'); nothing was written.`);
274
+ }
275
+ return readCapped(response, io.maxRideBytes ?? MAX_RIDE_BYTES);
276
+ }
277
+ /**
278
+ * The body, refused as soon as it passes `maxBytes`: up front when `Content-Length` says so, and
279
+ * while streaming otherwise, so a server that lies about it or streams without one cannot fill
280
+ * the memory or the disk.
281
+ */
282
+ async function readCapped(response, maxBytes) {
283
+ const tooLarge = () => new CliFailure(EXIT_TOOL_ERROR, 'RIDE_TOO_LARGE', `The RIDE download is larger than ${maxBytes} bytes; nothing was written.`, {
284
+ maxBytes,
285
+ });
286
+ const declared = Number(response.headers.get('content-length'));
287
+ if (Number.isFinite(declared) && declared > maxBytes) {
288
+ await response.body?.cancel().catch(() => undefined);
289
+ throw tooLarge();
290
+ }
291
+ if (!response.body)
292
+ return new Uint8Array();
293
+ const reader = response.body.getReader();
294
+ const chunks = [];
295
+ let total = 0;
296
+ try {
297
+ for (;;) {
298
+ const { done, value } = await reader.read();
299
+ if (done)
300
+ break;
301
+ total += value.byteLength;
302
+ if (total > maxBytes) {
303
+ await reader.cancel().catch(() => undefined);
304
+ throw tooLarge();
305
+ }
306
+ chunks.push(value);
307
+ }
308
+ }
309
+ catch (error) {
310
+ throw networkFailure(error);
311
+ }
312
+ const pdf = new Uint8Array(total);
313
+ let offset = 0;
314
+ for (const chunk of chunks) {
315
+ pdf.set(chunk, offset);
316
+ offset += chunk.byteLength;
317
+ }
318
+ return pdf;
232
319
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jipsoft/factusync-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "FactuSync command line for AI agents (Pi) and shell scripts: Ecuadorian SRI e-invoicing through the FactuSync MCP endpoint",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -41,7 +41,7 @@
41
41
  "prepublishOnly": "npm test"
42
42
  },
43
43
  "dependencies": {
44
- "@modelcontextprotocol/sdk": "1.30.1",
44
+ "@modelcontextprotocol/sdk": "1.32.1",
45
45
  "zod": "^3.25.0"
46
46
  },
47
47
  "devDependencies": {