@frihet/mcp-server 1.16.6 → 2.0.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.
Files changed (108) hide show
  1. package/README.md +168 -82
  2. package/dist/agent-onboarding.d.ts +98 -0
  3. package/dist/agent-onboarding.js +275 -0
  4. package/dist/api-origin.d.ts +9 -0
  5. package/dist/api-origin.js +41 -0
  6. package/dist/canary/mcp-harness.d.ts +77 -0
  7. package/dist/canary/mcp-harness.js +552 -0
  8. package/dist/capability-truth.d.ts +29 -0
  9. package/dist/capability-truth.js +262 -0
  10. package/dist/claude-candidate-contract.d.ts +482 -0
  11. package/dist/claude-candidate-contract.js +87 -0
  12. package/dist/claude-output-privacy.d.ts +17 -0
  13. package/dist/claude-output-privacy.js +240 -0
  14. package/dist/claude-profile.d.ts +18 -0
  15. package/dist/claude-profile.js +260 -0
  16. package/dist/client-interface.d.ts +47 -33
  17. package/dist/client.d.ts +172 -40
  18. package/dist/client.js +708 -165
  19. package/dist/demo-client.d.ts +41 -32
  20. package/dist/demo-client.js +202 -31
  21. package/dist/demo-fixtures.d.ts +3 -3
  22. package/dist/demo-fixtures.js +7 -7
  23. package/dist/deposit-contract.d.ts +29 -0
  24. package/dist/deposit-contract.js +76 -0
  25. package/dist/fetch-no-redirect.d.ts +3 -0
  26. package/dist/fetch-no-redirect.js +16 -0
  27. package/dist/fiscal-aliases.d.ts +44 -0
  28. package/dist/fiscal-aliases.js +62 -0
  29. package/dist/fiscal-period.d.ts +26 -0
  30. package/dist/fiscal-period.js +31 -0
  31. package/dist/google-profile.d.ts +19 -0
  32. package/dist/google-profile.js +122 -0
  33. package/dist/index.js +68 -59
  34. package/dist/logger.d.ts +2 -2
  35. package/dist/logger.js +140 -8
  36. package/dist/muse-profile.d.ts +19 -0
  37. package/dist/muse-profile.js +122 -0
  38. package/dist/observability.d.ts +18 -29
  39. package/dist/observability.js +194 -106
  40. package/dist/openai-profile.d.ts +26 -22
  41. package/dist/openai-profile.js +1193 -157
  42. package/dist/openai-review-contract.d.ts +15 -6
  43. package/dist/openai-review-contract.js +714 -41
  44. package/dist/openai-review-oauth.d.ts +149 -2
  45. package/dist/openai-review-oauth.js +220 -6
  46. package/dist/prompts/register-all.d.ts +2 -0
  47. package/dist/prompts/register-all.js +34 -78
  48. package/dist/public-capability-contract.d.ts +39 -0
  49. package/dist/public-capability-contract.js +186 -0
  50. package/dist/redaction.d.ts +15 -0
  51. package/dist/redaction.js +83 -0
  52. package/dist/resources/register-all.js +154 -64
  53. package/dist/retry-after.d.ts +19 -0
  54. package/dist/retry-after.js +84 -0
  55. package/dist/server-composition.d.ts +19 -0
  56. package/dist/server-composition.js +74 -0
  57. package/dist/tool-exposure.d.ts +31 -27
  58. package/dist/tool-exposure.js +211 -87
  59. package/dist/tools/accountingClose.d.ts +14 -5
  60. package/dist/tools/accountingClose.js +41 -23
  61. package/dist/tools/audit_gl.js +8 -8
  62. package/dist/tools/backend-availability.d.ts +8 -29
  63. package/dist/tools/backend-availability.js +31 -30
  64. package/dist/tools/bank_rules.js +66 -15
  65. package/dist/tools/banking.js +5 -13
  66. package/dist/tools/clients.js +261 -15
  67. package/dist/tools/crm.js +22 -21
  68. package/dist/tools/deposits.js +114 -85
  69. package/dist/tools/einvoice.d.ts +16 -2
  70. package/dist/tools/einvoice.js +59 -12
  71. package/dist/tools/expenses.d.ts +16 -0
  72. package/dist/tools/expenses.js +47 -27
  73. package/dist/tools/fiscal.d.ts +3 -4
  74. package/dist/tools/fiscal.js +93 -60
  75. package/dist/tools/gestoria.js +15 -7
  76. package/dist/tools/hr.d.ts +2 -3
  77. package/dist/tools/hr.js +49 -20
  78. package/dist/tools/igic.d.ts +9 -8
  79. package/dist/tools/igic.js +34 -64
  80. package/dist/tools/impuesto_sociedades.d.ts +4 -5
  81. package/dist/tools/impuesto_sociedades.js +16 -37
  82. package/dist/tools/intelligence.js +166 -12
  83. package/dist/tools/invoices.js +224 -54
  84. package/dist/tools/kitchen.js +19 -9
  85. package/dist/tools/payroll.d.ts +5 -8
  86. package/dist/tools/payroll.js +19 -32
  87. package/dist/tools/permissions.d.ts +4 -7
  88. package/dist/tools/permissions.js +11 -14
  89. package/dist/tools/products.js +6 -6
  90. package/dist/tools/quotes.js +172 -26
  91. package/dist/tools/recurring.js +26 -25
  92. package/dist/tools/register-all.d.ts +2 -2
  93. package/dist/tools/register-all.js +9 -6
  94. package/dist/tools/search.d.ts +19 -0
  95. package/dist/tools/search.js +84 -0
  96. package/dist/tools/shared.d.ts +1445 -109
  97. package/dist/tools/shared.js +478 -102
  98. package/dist/tools/team.js +24 -28
  99. package/dist/tools/time.js +4 -12
  100. package/dist/tools/vendors.js +249 -15
  101. package/dist/tools/webhooks.js +35 -30
  102. package/dist/types.d.ts +31 -5
  103. package/dist/workflows/daily-operational-brief.d.ts +163 -0
  104. package/dist/workflows/daily-operational-brief.js +881 -0
  105. package/docs/agent-onboarding.json +391 -0
  106. package/package.json +49 -12
  107. package/dist/tools/onboarding.d.ts +0 -19
  108. package/dist/tools/onboarding.js +0 -64
package/dist/client.js CHANGED
@@ -11,11 +11,83 @@
11
11
  * (`req.query.cursor`, functions/src/publicApi.ts in Frihet-ERP). `after`
12
12
  * is never sent on the wire. See src/__tests__/pagination-cursor-param.test.ts.
13
13
  */
14
+ import { rejectRedirectResponse } from "./fetch-no-redirect.js";
15
+ import { safeRequestId, sanitizeServerRemediation } from "./redaction.js";
14
16
  import { logApiCall, logRetry } from "./logger.js";
17
+ import { parseRetryAfter } from "./retry-after.js";
18
+ import { fiscalModeloQuery } from "./fiscal-period.js";
19
+ import { normalizeApplyDepositBody, normalizeDepositBody } from "./deposit-contract.js";
15
20
  const BASE_URL = "https://api.frihet.io/v1";
16
21
  const MAX_RETRIES = 3;
17
22
  const DEFAULT_RETRY_DELAY_MS = 1000;
18
23
  const REQUEST_TIMEOUT_MS = 30000;
24
+ /** Total time one logical call may spend sleeping on 429s before it is deferred to the caller. */
25
+ const RETRY_BUDGET_MS = 30000;
26
+ /** setTimeout fires immediately above this (32-bit signed); a larger wait must defer, never sleep. */
27
+ const MAX_TIMER_MS = 2 ** 31 - 1;
28
+ /**
29
+ * Hard size caps for document responses. Enforced TWICE: precheck on
30
+ * `Content-Length` (so an honest server doesn't waste bandwidth) and after
31
+ * streaming (so a missing/lying `Content-Length` still can't trigger an
32
+ * unbounded allocation).
33
+ *
34
+ * - PDF: 25 MiB — generous for any ERP-issued invoice PDF, including
35
+ * embedded logos, Facturae XML attachments, and stamp signatures.
36
+ * - XML: 5 MiB — UBL / Facturae / PEPPOL documents stay well under 1 MiB
37
+ * in practice; 5 MiB absorbs any historical / annex-laden outlier.
38
+ *
39
+ * Anything larger is rejected with `413 payload_too_large` BEFORE we allocate
40
+ * — the user sees a clean error, the worker doesn't OOM.
41
+ */
42
+ export const MAX_PDF_BYTES = 25 * 1024 * 1024;
43
+ export const MAX_XML_BYTES = 5 * 1024 * 1024;
44
+ const MAX_ERROR_BYTES = 64 * 1024;
45
+ /** One exhaustive translation at the HTTP boundary. Never send the MCP's
46
+ * lowercase convenience values to the strict API. Factur-X Extended, Basic
47
+ * and Minimum are intentionally absent: collapsing them to EN16931 would lie
48
+ * about the generated profile. */
49
+ const E_INVOICE_EXPORT_API_FORMAT = {
50
+ facturae: "Facturae",
51
+ "xrechnung-cii": "XRechnung-CII",
52
+ "xrechnung-ubl": "XRechnung-UBL",
53
+ "facturx-en16931": "Factur-X",
54
+ fatturapa: "FatturaPA",
55
+ "peppol-bis-3": "PEPPOL-BIS-3",
56
+ "fa-2-ksef": "FA-2-KSeF",
57
+ ubl: "UBL",
58
+ cii: "CII",
59
+ };
60
+ // Only the existing guards below can attest that a request was never sent.
61
+ // Provider error codes, copied errors and public properties cannot forge this
62
+ // process-local provenance. Both runtimes use this fetch-only client.
63
+ const LOCAL_VALIDATION_ERRORS = new WeakSet();
64
+ export function isLocalInputValidationError(error) {
65
+ return typeof error === "object" && error !== null && LOCAL_VALIDATION_ERRORS.has(error);
66
+ }
67
+ export function toApiEInvoiceExportFormat(format) {
68
+ if (!Object.hasOwn(E_INVOICE_EXPORT_API_FORMAT, format)) {
69
+ const error = new FrihetApiError(400, "unsupported_einvoice_export_format", "Unsupported e-invoice export format. Factur-X export supports the EN16931 profile only.");
70
+ LOCAL_VALIDATION_ERRORS.add(error);
71
+ throw error;
72
+ }
73
+ return E_INVOICE_EXPORT_API_FORMAT[format];
74
+ }
75
+ /**
76
+ * Encode one caller-supplied path segment. `encodeURIComponent` leaves "." and
77
+ * ".." unchanged and URL parsing resolves them as dot-segments, so either value
78
+ * would change the route a request reaches; an empty value would drop the
79
+ * segment. Every id interpolated into a request path goes through here, and
80
+ * those three values are refused before any request is sent.
81
+ */
82
+ function pathSegment(value) {
83
+ const segment = String(value);
84
+ if (segment === "" || segment === "." || segment === "..") {
85
+ const error = new FrihetApiError(400, "invalid_path_segment", 'Invalid identifier: an id cannot be empty, "." or "..".');
86
+ LOCAL_VALIDATION_ERRORS.add(error);
87
+ throw error;
88
+ }
89
+ return encodeURIComponent(segment);
90
+ }
19
91
  /**
20
92
  * HTTP methods for which the backend treats the request as a mutation and
21
93
  * therefore accepts (and for some endpoints REQUIRES) an `Idempotency-Key`
@@ -55,12 +127,11 @@ const SOURCE_USER_AGENT = "frihet-mcp-server";
55
127
  /**
56
128
  * Fresh idempotency key, always a syntactically valid UUID v4.
57
129
  *
58
- * `crypto.randomUUID` is a global on the Cloudflare Workers runtime and on
59
- * Node >= 19. On Node 18 — our declared `engines` floor — `globalThis.crypto`
60
- * is behind `--experimental-global-webcrypto`, so the fallback is a REAL code
61
- * path there, not a theoretical one. It therefore has to produce a UUID and
62
- * not an ad-hoc string: the backend documents "UUID v4 recommended", and
63
- * src/__tests__/idempotency-key-contract.test.ts asserts the shape.
130
+ * `crypto.randomUUID` is global on Cloudflare Workers and the supported Node
131
+ * runtime. The fallback remains defense in depth for constrained embedders and
132
+ * tests that intentionally remove the global; it must still produce a UUID,
133
+ * not an ad-hoc string. The backend recommends UUID v4 and the contract test
134
+ * asserts the shape.
64
135
  */
65
136
  function newIdempotencyKey() {
66
137
  const c = globalThis.crypto;
@@ -86,31 +157,170 @@ function normalizeIdempotencyKey(key) {
86
157
  const trimmed = key?.trim();
87
158
  return trimmed ? trimmed : undefined;
88
159
  }
160
+ function normalizedContentType(contentType) {
161
+ return contentType.split(";", 1)[0].trim().toLowerCase();
162
+ }
163
+ function isXmlContentType(contentType) {
164
+ const normalized = normalizedContentType(contentType);
165
+ return normalized === "application/xml" || normalized === "text/xml" || normalized.endsWith("+xml");
166
+ }
167
+ function hasPdfSignature(bytes) {
168
+ return bytes.byteLength >= 5 &&
169
+ bytes[0] === 0x25 &&
170
+ bytes[1] === 0x50 &&
171
+ bytes[2] === 0x44 &&
172
+ bytes[3] === 0x46 &&
173
+ bytes[4] === 0x2d;
174
+ }
175
+ function attachmentFilename(contentDisposition) {
176
+ if (!contentDisposition)
177
+ return undefined;
178
+ const encoded = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
179
+ const quoted = contentDisposition.match(/filename="([^"]+)"/i)?.[1];
180
+ const plain = contentDisposition.match(/filename=([^;]+)/i)?.[1];
181
+ const candidate = (encoded ?? quoted ?? plain)?.trim();
182
+ if (!candidate)
183
+ return undefined;
184
+ try {
185
+ return decodeURIComponent(candidate).split(/[\\/]/).pop();
186
+ }
187
+ catch {
188
+ return candidate.split(/[\\/]/).pop();
189
+ }
190
+ }
191
+ function isAbortError(error) {
192
+ return error instanceof Error && error.name === "AbortError";
193
+ }
194
+ function decodeUtf8(bytes, label) {
195
+ try {
196
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
197
+ }
198
+ catch {
199
+ throw new FrihetApiError(200, "invalid_response", `${label} is not valid UTF-8`);
200
+ }
201
+ }
202
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
203
+ /** Encode bytes without relying on Node's Buffer in browser/Worker runtimes. */
204
+ function encodeBase64(bytes) {
205
+ let encoded = "";
206
+ for (let index = 0; index < bytes.length; index += 3) {
207
+ const first = bytes[index];
208
+ const hasSecond = index + 1 < bytes.length;
209
+ const hasThird = index + 2 < bytes.length;
210
+ const second = hasSecond ? bytes[index + 1] : 0;
211
+ const third = hasThird ? bytes[index + 2] : 0;
212
+ const chunk = (first << 16) | (second << 8) | third;
213
+ encoded += BASE64_ALPHABET[(chunk >>> 18) & 0x3f];
214
+ encoded += BASE64_ALPHABET[(chunk >>> 12) & 0x3f];
215
+ encoded += hasSecond ? BASE64_ALPHABET[(chunk >>> 6) & 0x3f] : "=";
216
+ encoded += hasThird ? BASE64_ALPHABET[chunk & 0x3f] : "=";
217
+ }
218
+ return encoded;
219
+ }
89
220
  export class FrihetApiError extends Error {
90
221
  statusCode;
91
222
  errorCode;
92
- constructor(statusCode, errorCode, message) {
93
- super(message ?? errorCode);
223
+ detail;
224
+ requestId;
225
+ constructor(statusCode, errorCode, message, detail,
226
+ /** Backend correlation id (`meta.requestId` or `X-Request-Id`); carried for diagnostics, never echoed to agents. */
227
+ requestId) {
228
+ super(statusCode === 403
229
+ ? sanitizeServerRemediation(message, errorCode) ?? errorCode
230
+ : message ?? errorCode);
94
231
  this.statusCode = statusCode;
95
232
  this.errorCode = errorCode;
233
+ this.detail = detail;
234
+ this.requestId = requestId;
96
235
  this.name = "FrihetApiError";
97
236
  }
98
237
  }
238
+ /**
239
+ * Enforce the webhook secret's one-time write echo and normalize the one
240
+ * Firestore Timestamp that the ERP webhook route currently emits without its
241
+ * usual serializer. Invalid timestamp objects are preserved so the strict MCP
242
+ * output schema fails closed instead of fabricating a value.
243
+ */
244
+ function normalizeWebhookRecord(webhook, allowedSecret) {
245
+ const normalized = { ...webhook };
246
+ if (typeof allowedSecret !== "string" ||
247
+ typeof normalized.secret !== "string" ||
248
+ normalized.secret !== allowedSecret) {
249
+ delete normalized.secret;
250
+ }
251
+ const lastTriggeredAt = normalized.lastTriggeredAt;
252
+ if (lastTriggeredAt !== null &&
253
+ typeof lastTriggeredAt === "object" &&
254
+ !Array.isArray(lastTriggeredAt)) {
255
+ const timestamp = lastTriggeredAt;
256
+ const seconds = timestamp._seconds;
257
+ const nanoseconds = timestamp._nanoseconds;
258
+ if (typeof seconds === "number" &&
259
+ Number.isInteger(seconds) &&
260
+ Number.isFinite(seconds) &&
261
+ typeof nanoseconds === "number" &&
262
+ Number.isInteger(nanoseconds) &&
263
+ nanoseconds >= 0 &&
264
+ nanoseconds < 1_000_000_000) {
265
+ const date = new Date(seconds * 1000 + Math.floor(nanoseconds / 1_000_000));
266
+ if (Number.isFinite(date.getTime())) {
267
+ normalized.lastTriggeredAt = date.toISOString();
268
+ }
269
+ }
270
+ }
271
+ return normalized;
272
+ }
273
+ function normalizeApiError(value, statusCode, statusText) {
274
+ const fallbackError = `http_${statusCode}`;
275
+ const record = typeof value === "object" && value !== null
276
+ ? value
277
+ : undefined;
278
+ // publicApi error envelopes carry `meta: { requestId }` (Frihet-ERP
279
+ // functions/src/publicApi.ts, e.g. the 401/404 branches).
280
+ const meta = typeof record?.meta === "object" && record.meta !== null
281
+ ? record.meta
282
+ : undefined;
283
+ const requestId = safeRequestId(meta?.requestId);
284
+ const error = typeof record?.error === "string" && record.error.trim()
285
+ ? record.error
286
+ : fallbackError;
287
+ const message = typeof record?.message === "string"
288
+ ? record.message
289
+ : (error !== fallbackError ? error : statusText || fallbackError);
290
+ return {
291
+ error,
292
+ message,
293
+ ...(typeof record?.detail === "string" ? { detail: record.detail } : {}),
294
+ ...(requestId ? { requestId } : {}),
295
+ };
296
+ }
99
297
  export class FrihetClient {
100
298
  apiKey;
101
299
  baseUrl;
102
300
  timeoutMs;
301
+ retryBudgetMs;
302
+ sleepFn;
303
+ nowFn;
103
304
  constructor(apiKey, baseUrl, options) {
104
305
  if (!apiKey) {
105
306
  throw new Error("FRIHET_API_KEY is required. Set it as an environment variable or pass it to the constructor.");
106
307
  }
308
+ const resolvedBaseUrl = baseUrl ?? BASE_URL;
107
309
  this.apiKey = apiKey;
108
- this.baseUrl = baseUrl ?? BASE_URL;
310
+ this.baseUrl = resolvedBaseUrl;
109
311
  this.timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
312
+ const budget = options?.retryBudgetMs;
313
+ // NaN/negative would make every `> budget` comparison pass or fail silently.
314
+ this.retryBudgetMs =
315
+ typeof budget === "number" && Number.isFinite(budget) && budget >= 0
316
+ ? Math.min(budget, MAX_TIMER_MS)
317
+ : RETRY_BUDGET_MS;
318
+ this.sleepFn = options?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
319
+ this.nowFn = options?.now ?? Date.now;
110
320
  }
111
321
  // ------------------------------------------------------------------ HTTP
112
322
  // ------------------------------------------------------------------
113
- async request(method, path, body, query, retryCount = 0, idempotencyKey) {
323
+ async request(method, path, body, query, retryCount = 0, idempotencyKey, waitedMs = 0) {
114
324
  const url = new URL(`${this.baseUrl}${path}`);
115
325
  // Resolved once, at the top of the call chain: a caller-supplied key wins,
116
326
  // otherwise mutations get a freshly minted one. The resolved value is then
@@ -143,67 +353,251 @@ export class FrihetClient {
143
353
  try {
144
354
  response = await fetch(url.toString(), {
145
355
  method,
356
+ redirect: "manual",
146
357
  headers,
147
358
  body: body ? JSON.stringify(body) : undefined,
148
359
  signal: controller.signal,
149
- });
360
+ }).then(rejectRedirectResponse);
150
361
  }
151
362
  catch (error) {
363
+ clearTimeout(timeoutId);
152
364
  const durationMs = Math.round(Date.now() - startTime);
153
365
  if (error instanceof Error && error.name === "AbortError") {
154
366
  logApiCall(method, path, 408, durationMs);
155
367
  throw new FrihetApiError(408, "request_timeout", `Request timed out after ${this.timeoutMs / 1000} seconds`);
156
368
  }
157
369
  logApiCall(method, path, 0, durationMs);
158
- throw error;
159
- }
160
- finally {
161
- clearTimeout(timeoutId);
370
+ throw new FrihetApiError(503, "network_error", "The network request failed before Frihet could confirm its outcome.");
162
371
  }
163
372
  const durationMs = Math.round(Date.now() - startTime);
164
373
  // Rate limit handling
165
374
  if (response.status === 429) {
375
+ clearTimeout(timeoutId);
166
376
  logApiCall(method, path, 429, durationMs);
167
377
  if (retryCount >= MAX_RETRIES) {
168
378
  throw new FrihetApiError(429, "rate_limit_exceeded", "Rate limit exceeded after multiple retries. Please try again later.");
169
379
  }
170
- const retryAfter = response.headers.get("Retry-After");
171
- const delayMs = retryAfter
172
- ? parseInt(retryAfter, 10) * 1000
173
- : DEFAULT_RETRY_DELAY_MS * Math.pow(2, retryCount);
174
- logRetry(method, path, retryCount, delayMs);
175
- await this.sleep(delayMs);
176
- return this.request(method, path, body, query, retryCount + 1, resolvedIdempotencyKey);
380
+ // Release the discarded 429 body before waiting so the socket is freed.
381
+ // (requestDocument has already drained it in readBoundedBody.)
382
+ await response.body?.cancel().catch(() => undefined);
383
+ const delayMs = this.rateLimitDelayMs(response.headers.get("Retry-After"), retryCount, waitedMs, method, path);
384
+ await this.sleepFn(delayMs);
385
+ return this.request(method, path, body, query, retryCount + 1, resolvedIdempotencyKey, waitedMs + delayMs);
177
386
  }
178
387
  // Error responses
179
388
  if (!response.ok) {
180
389
  logApiCall(method, path, response.status, durationMs);
181
390
  let errorBody;
182
391
  try {
183
- errorBody = (await response.json());
392
+ errorBody = normalizeApiError(await response.json(), response.status, response.statusText);
184
393
  }
185
394
  catch {
186
- errorBody = {
187
- error: `http_${response.status}`,
188
- message: response.statusText,
189
- };
395
+ errorBody = normalizeApiError(undefined, response.status, response.statusText);
190
396
  }
191
- throw new FrihetApiError(response.status, errorBody.error, errorBody.message ?? errorBody.error);
397
+ clearTimeout(timeoutId);
398
+ throw new FrihetApiError(response.status, errorBody.error, errorBody.message ?? errorBody.error, typeof errorBody.detail === "string" ? errorBody.detail : undefined, errorBody.requestId ?? safeRequestId(response.headers.get("x-request-id")));
192
399
  }
193
400
  logApiCall(method, path, response.status, durationMs);
194
401
  // 204 No Content (e.g. DELETE)
195
402
  if (response.status === 204) {
403
+ clearTimeout(timeoutId);
196
404
  return undefined;
197
405
  }
198
- const data = await response.json();
406
+ let data;
407
+ try {
408
+ // Keep the request AbortSignal live through body consumption. A server
409
+ // can send 2xx headers after committing a write and then stall or truncate
410
+ // the JSON body; clearing the timer at headers would let this hang forever
411
+ // and make a committed mutation look safely retryable.
412
+ data = await response.json();
413
+ }
414
+ catch (error) {
415
+ clearTimeout(timeoutId);
416
+ const timedOut = error instanceof Error && error.name === "AbortError";
417
+ throw new FrihetApiError(timedOut ? 408 : response.status, timedOut ? "response_timeout" : "invalid_response_after_success", timedOut
418
+ ? `Response body timed out after ${this.timeoutMs / 1000} seconds`
419
+ : "Frihet returned an unreadable success response after processing the request.");
420
+ }
421
+ clearTimeout(timeoutId);
199
422
  // Basic response validation
200
423
  if (data === null || data === undefined) {
201
- throw new FrihetApiError(response.status, 'invalid_response', 'API returned empty response');
424
+ throw new FrihetApiError(response.status, "invalid_response_after_success", "Frihet returned an empty success response after processing the request.");
202
425
  }
203
426
  return data;
204
427
  }
205
- sleep(ms) {
206
- return new Promise((resolve) => setTimeout(resolve, ms));
428
+ /** Fetch a raw response. The caller owns timeout and body consumption. */
429
+ async fetchRaw(method, path, body, query, idempotencyKey, signal) {
430
+ const resolvedIdempotencyKey = normalizeIdempotencyKey(idempotencyKey) ??
431
+ (MUTATING_METHODS.has(method) ? newIdempotencyKey() : undefined);
432
+ const url = new URL(`${this.baseUrl}${path}`);
433
+ if (query) {
434
+ for (const [key, value] of Object.entries(query)) {
435
+ if (value !== undefined)
436
+ url.searchParams.set(key, String(value));
437
+ }
438
+ }
439
+ const headers = {
440
+ "X-API-Key": this.apiKey,
441
+ "Content-Type": "application/json",
442
+ // CRITICAL: do NOT set `Accept: application/json` here. The PDF / XML
443
+ // endpoints must be allowed to return their native content type;
444
+ // forcing `Accept: application/json` would make the server negotiate
445
+ // an error envelope instead of the document bytes.
446
+ Accept: "*/*",
447
+ "X-Frihet-Source": SOURCE_MARKER,
448
+ "User-Agent": SOURCE_USER_AGENT,
449
+ };
450
+ if (resolvedIdempotencyKey) {
451
+ headers["Idempotency-Key"] = resolvedIdempotencyKey;
452
+ }
453
+ return fetch(url.toString(), {
454
+ method,
455
+ redirect: "manual",
456
+ headers,
457
+ body: body ? JSON.stringify(body) : undefined,
458
+ signal,
459
+ }).then(rejectRedirectResponse);
460
+ }
461
+ /** Consume one response body without ever retaining more than `maxBytes`. */
462
+ async readBoundedBody(response, maxBytes, controller) {
463
+ const declaredLengthRaw = response.headers.get("content-length")?.trim();
464
+ const declaredLength = declaredLengthRaw && /^\d+$/.test(declaredLengthRaw)
465
+ ? Number(declaredLengthRaw)
466
+ : Number.NaN;
467
+ if (Number.isSafeInteger(declaredLength) && declaredLength > maxBytes) {
468
+ controller.abort();
469
+ try {
470
+ await response.body?.cancel();
471
+ }
472
+ catch { /* best effort */ }
473
+ throw new FrihetApiError(413, "payload_too_large", `Document response exceeds ${maxBytes} bytes (Content-Length: ${declaredLength})`);
474
+ }
475
+ if (!response.body) {
476
+ throw new FrihetApiError(response.status, "invalid_response", "Response body is null");
477
+ }
478
+ const reader = response.body.getReader();
479
+ const chunks = [];
480
+ let totalBytes = 0;
481
+ try {
482
+ while (true) {
483
+ const { value, done } = await reader.read();
484
+ if (done)
485
+ break;
486
+ if (!value)
487
+ continue;
488
+ totalBytes += value.byteLength;
489
+ if (totalBytes > maxBytes) {
490
+ controller.abort();
491
+ try {
492
+ await reader.cancel("document response exceeded size limit");
493
+ }
494
+ catch { /* best effort */ }
495
+ throw new FrihetApiError(413, "payload_too_large", `Document response exceeds ${maxBytes} bytes during streaming`);
496
+ }
497
+ chunks.push(value);
498
+ }
499
+ }
500
+ catch (error) {
501
+ if (error instanceof FrihetApiError || isAbortError(error))
502
+ throw error;
503
+ try {
504
+ await reader.cancel("document stream failed");
505
+ }
506
+ catch { /* best effort */ }
507
+ controller.abort();
508
+ throw new FrihetApiError(response.status, "stream_failed", `Failed while reading document response: ${error instanceof Error ? error.message : String(error)}`);
509
+ }
510
+ finally {
511
+ reader.releaseLock();
512
+ }
513
+ const flat = new Uint8Array(totalBytes);
514
+ let offset = 0;
515
+ for (const chunk of chunks) {
516
+ flat.set(chunk, offset);
517
+ offset += chunk.byteLength;
518
+ }
519
+ return flat;
520
+ }
521
+ /**
522
+ * Bounded document fetch — ONE call, ONE Response, dispatch on Content-Type.
523
+ *
524
+ * Success and error bodies use the same bounded reader. The abort timer stays
525
+ * active until the body is complete, and a normal success performs one GET.
526
+ * A 429 may retry, matching the generic JSON client path.
527
+ */
528
+ async requestDocument(method, path, body, query, idempotencyKey, maxBytesForContentType, retryCount = 0, waitedMs = 0) {
529
+ const controller = new AbortController();
530
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
531
+ const startedAt = Date.now();
532
+ try {
533
+ const response = await this.fetchRaw(method, path, body, query, idempotencyKey, controller.signal);
534
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
535
+ const maxBytes = response.ok ? maxBytesForContentType(contentType) : MAX_ERROR_BYTES;
536
+ const bytes = await this.readBoundedBody(response, maxBytes, controller);
537
+ const durationMs = Math.round(Date.now() - startedAt);
538
+ if (response.status === 429) {
539
+ logApiCall(method, path, 429, durationMs);
540
+ if (retryCount >= MAX_RETRIES) {
541
+ throw new FrihetApiError(429, "rate_limit_exceeded", "Rate limit exceeded after multiple retries. Please try again later.");
542
+ }
543
+ clearTimeout(timeoutId);
544
+ const delayMs = this.rateLimitDelayMs(response.headers.get("Retry-After"), retryCount, waitedMs, method, path);
545
+ await this.sleepFn(delayMs);
546
+ return this.requestDocument(method, path, body, query, idempotencyKey, maxBytesForContentType, retryCount + 1, waitedMs + delayMs);
547
+ }
548
+ if (!response.ok) {
549
+ logApiCall(method, path, response.status, durationMs);
550
+ let errorBody = normalizeApiError(undefined, response.status, response.statusText);
551
+ try {
552
+ errorBody = normalizeApiError(JSON.parse(decodeUtf8(bytes, "API error response")), response.status, response.statusText);
553
+ }
554
+ catch (error) {
555
+ if (error instanceof FrihetApiError && error.errorCode !== "invalid_response")
556
+ throw error;
557
+ }
558
+ throw new FrihetApiError(response.status, errorBody.error, errorBody.message ?? errorBody.error, typeof errorBody.detail === "string" ? errorBody.detail : undefined, errorBody.requestId ?? safeRequestId(response.headers.get("x-request-id")));
559
+ }
560
+ logApiCall(method, path, response.status, durationMs);
561
+ const filename = attachmentFilename(response.headers.get("content-disposition"));
562
+ return {
563
+ contentType,
564
+ bytes,
565
+ sizeBytes: bytes.byteLength,
566
+ ...(filename ? { filename } : {}),
567
+ };
568
+ }
569
+ catch (error) {
570
+ if (isAbortError(error)) {
571
+ const durationMs = Math.round(Date.now() - startedAt);
572
+ logApiCall(method, path, 408, durationMs);
573
+ throw new FrihetApiError(408, "request_timeout", `Request timed out after ${this.timeoutMs / 1000} seconds`);
574
+ }
575
+ if (error instanceof FrihetApiError)
576
+ throw error;
577
+ const durationMs = Math.round(Date.now() - startedAt);
578
+ logApiCall(method, path, 0, durationMs);
579
+ throw new FrihetApiError(503, "network_error", "The network request failed before Frihet could confirm its outcome.");
580
+ }
581
+ finally {
582
+ clearTimeout(timeoutId);
583
+ }
584
+ }
585
+ /**
586
+ * Wait before the next 429 retry: the server's `Retry-After` when it parses
587
+ * (RFC 9110 §10.2.3), otherwise exponential backoff. Either wait that does
588
+ * not fit the remaining cumulative budget is deferred to the caller; a server
589
+ * wait is never shortened into an early retry.
590
+ */
591
+ rateLimitDelayMs(retryAfter, retryCount, waitedMs, method, path) {
592
+ const serverMs = parseRetryAfter(retryAfter, this.nowFn());
593
+ const delayMs = serverMs ?? DEFAULT_RETRY_DELAY_MS * Math.pow(2, retryCount);
594
+ if (waitedMs + delayMs > this.retryBudgetMs) {
595
+ throw new FrihetApiError(429, "rate_limit_deferred", serverMs !== null
596
+ ? `Rate limited: the server asks to wait ${Math.ceil(serverMs / 1000)} seconds, which exceeds this operation's retry budget. Retry after that time; nothing was retried early.`
597
+ : "Rate limited: the next backoff wait exceeds this operation's retry budget. Retry later.");
598
+ }
599
+ logRetry(method, path, retryCount, delayMs, serverMs === null ? "backoff" : "retry_after");
600
+ return delayMs;
207
601
  }
208
602
  /**
209
603
  * Wrapper for endpoints whose backend wraps the payload in a `{ data, meta }`
@@ -220,15 +614,20 @@ export class FrihetClient {
220
614
  * output schema (id/clientName/items/success all read as `undefined`). This
221
615
  * unwraps to `body.data`. Every create/update/action mutation routes through
222
616
  * here for exactly this reason; single-object reads (`getInvoice`/…) have since
223
- * #64. `deleteX` methods deliberately keep `request` (they return `void` — the
224
- * tool discards the body and synthesizes `{ success, id }`, so there is nothing
225
- * to unwrap). Only unwraps a non-array-object `data` (see guard below), so an
617
+ * #64. Most `deleteX` methods keep `request` (they return `void` — the tool
618
+ * discards the body and synthesizes `{ success, id }`, so there is nothing to
619
+ * unwrap). `deleteInvoice`/`deleteQuote` are the EXCEPTION and route through
620
+ * here: their backend answers 204 for a real delete but 200 + `{ data, meta }`
621
+ * when it soft-CANCELS a non-draft document instead (see those methods).
622
+ * Only unwraps a non-array-object `data` (see guard below), so an
226
623
  * array-`data` list envelope and any non-enveloped body pass through unchanged.
624
+ * A 204 yields `undefined` from `request`, which falls through untouched — that
625
+ * `undefined` is what tells the delete tools "destroyed, not cancelled".
227
626
  *
228
627
  * Only unwraps when the body is an object carrying a `data` property that is
229
628
  * itself a (non-array) object — i.e. a genuine single-object envelope. It is
230
629
  * deliberately distinct from {@link requestPaginated}, which keeps the
231
- * `{ data: [...] , meta }` shape intact for list endpoints. If the body is
630
+ * list rows and pagination fields while omitting transport metadata. If the body is
232
631
  * not an envelope (legacy endpoints that return the item directly), it is
233
632
  * returned unchanged so existing callers keep working.
234
633
  */
@@ -248,13 +647,21 @@ export class FrihetClient {
248
647
  }
249
648
  return raw;
250
649
  }
251
- /** Wrapper for paginated endpoints — validates response shape has `data` array. */
650
+ /** Validate list rows and emit only the declared MCP pagination envelope. */
252
651
  async requestPaginated(method, path, body, query) {
253
652
  const result = await this.request(method, path, body, query);
254
653
  if (!result || !Array.isArray(result.data)) {
255
654
  throw new FrihetApiError(200, 'invalid_response', 'API returned invalid paginated response');
256
655
  }
257
- return result;
656
+ // API transport metadata is not part of the closed MCP page contract.
657
+ // Keep rows untouched and preserve every declared pagination field.
658
+ return {
659
+ data: result.data,
660
+ total: result.total,
661
+ limit: result.limit,
662
+ offset: result.offset,
663
+ ...(result.nextCursor !== undefined ? { nextCursor: result.nextCursor } : {}),
664
+ };
258
665
  }
259
666
  // ---------------------------------------------------------------- Public
260
667
  // ----------------------------------------------------------------
@@ -274,16 +681,30 @@ export class FrihetClient {
274
681
  });
275
682
  }
276
683
  async getInvoice(id) {
277
- return this.requestUnwrapped("GET", `/invoices/${encodeURIComponent(id)}`);
684
+ return this.requestUnwrapped("GET", `/invoices/${pathSegment(id)}`);
278
685
  }
279
686
  async createInvoice(data) {
280
687
  return this.requestUnwrapped("POST", "/invoices", data);
281
688
  }
282
689
  async updateInvoice(id, data) {
283
- return this.requestUnwrapped("PATCH", `/invoices/${encodeURIComponent(id)}`, data);
690
+ return this.requestUnwrapped("PATCH", `/invoices/${pathSegment(id)}`, data);
284
691
  }
692
+ /**
693
+ * DELETE /invoices/{id} — NOT an unconditional destroy.
694
+ *
695
+ * The backend refuses to destroy a non-draft invoice (VeriFactu hash-chain
696
+ * integrity) and soft-CANCELS it instead, and it distinguishes the two
697
+ * outcomes on the wire (`publicApi.ts` DELETE branch):
698
+ * - 204 No Content → the draft row was really removed
699
+ * - 200 + `{ data: { id, status: 'cancelled', previousStatus,
700
+ * cancelledVia }, meta }` → the document still exists, now cancelled
701
+ *
702
+ * This used to be typed `Promise<void>` and the body was thrown away, so the
703
+ * tool reported "deleted successfully" for a document that was still there.
704
+ * Resolves to the unwrapped soft-cancel payload on 200, `undefined` on 204.
705
+ */
285
706
  async deleteInvoice(id) {
286
- return this.request("DELETE", `/invoices/${encodeURIComponent(id)}`);
707
+ return this.requestUnwrapped("DELETE", `/invoices/${pathSegment(id)}`);
287
708
  }
288
709
  async searchInvoices(query, params) {
289
710
  return this.requestPaginated("GET", "/invoices", undefined, {
@@ -312,16 +733,16 @@ export class FrihetClient {
312
733
  });
313
734
  }
314
735
  async getExpense(id) {
315
- return this.requestUnwrapped("GET", `/expenses/${encodeURIComponent(id)}`);
736
+ return this.requestUnwrapped("GET", `/expenses/${pathSegment(id)}`);
316
737
  }
317
738
  async createExpense(data) {
318
739
  return this.requestUnwrapped("POST", "/expenses", data);
319
740
  }
320
741
  async updateExpense(id, data) {
321
- return this.requestUnwrapped("PATCH", `/expenses/${encodeURIComponent(id)}`, data);
742
+ return this.requestUnwrapped("PATCH", `/expenses/${pathSegment(id)}`, data);
322
743
  }
323
744
  async deleteExpense(id) {
324
- return this.request("DELETE", `/expenses/${encodeURIComponent(id)}`);
745
+ return this.request("DELETE", `/expenses/${pathSegment(id)}`);
325
746
  }
326
747
  // ---------------------------------------------------------------- Clients
327
748
  // ----------------------------------------------------------------
@@ -336,16 +757,16 @@ export class FrihetClient {
336
757
  });
337
758
  }
338
759
  async getClient(id) {
339
- return this.requestUnwrapped("GET", `/clients/${encodeURIComponent(id)}`);
760
+ return this.requestUnwrapped("GET", `/clients/${pathSegment(id)}`);
340
761
  }
341
762
  async createClient(data) {
342
763
  return this.requestUnwrapped("POST", "/clients", data);
343
764
  }
344
765
  async updateClient(id, data) {
345
- return this.requestUnwrapped("PATCH", `/clients/${encodeURIComponent(id)}`, data);
766
+ return this.requestUnwrapped("PATCH", `/clients/${pathSegment(id)}`, data);
346
767
  }
347
768
  async deleteClient(id) {
348
- return this.request("DELETE", `/clients/${encodeURIComponent(id)}`);
769
+ return this.request("DELETE", `/clients/${pathSegment(id)}`);
349
770
  }
350
771
  // ---------------------------------------------------------------- Products
351
772
  // ----------------------------------------------------------------
@@ -356,20 +777,22 @@ export class FrihetClient {
356
777
  cursor: params?.after,
357
778
  fields: params?.fields,
358
779
  q: params?.q,
359
- isActive: params?.isActive !== undefined ? (params.isActive ? 1 : 0) : undefined,
780
+ // Public API accepts the literal strings "true"/"false". Numeric 1/0
781
+ // makes `isActive=true` evaluate as false in the backend parser.
782
+ isActive: params?.isActive !== undefined ? String(params.isActive) : undefined,
360
783
  });
361
784
  }
362
785
  async getProduct(id) {
363
- return this.requestUnwrapped("GET", `/products/${encodeURIComponent(id)}`);
786
+ return this.requestUnwrapped("GET", `/products/${pathSegment(id)}`);
364
787
  }
365
788
  async createProduct(data) {
366
789
  return this.requestUnwrapped("POST", "/products", data);
367
790
  }
368
791
  async updateProduct(id, data) {
369
- return this.requestUnwrapped("PATCH", `/products/${encodeURIComponent(id)}`, data);
792
+ return this.requestUnwrapped("PATCH", `/products/${pathSegment(id)}`, data);
370
793
  }
371
794
  async deleteProduct(id) {
372
- return this.request("DELETE", `/products/${encodeURIComponent(id)}`);
795
+ return this.request("DELETE", `/products/${pathSegment(id)}`);
373
796
  }
374
797
  // ---------------------------------------------------------------- Quotes
375
798
  // ----------------------------------------------------------------
@@ -387,16 +810,22 @@ export class FrihetClient {
387
810
  });
388
811
  }
389
812
  async getQuote(id) {
390
- return this.requestUnwrapped("GET", `/quotes/${encodeURIComponent(id)}`);
813
+ return this.requestUnwrapped("GET", `/quotes/${pathSegment(id)}`);
391
814
  }
392
815
  async createQuote(data) {
393
816
  return this.requestUnwrapped("POST", "/quotes", data);
394
817
  }
395
818
  async updateQuote(id, data) {
396
- return this.requestUnwrapped("PATCH", `/quotes/${encodeURIComponent(id)}`, data);
819
+ return this.requestUnwrapped("PATCH", `/quotes/${pathSegment(id)}`, data);
397
820
  }
821
+ /**
822
+ * DELETE /quotes/{id} — same two-outcome contract as {@link deleteInvoice}:
823
+ * the backend soft-CANCELS a non-draft quote (200 + body), destroys only a
824
+ * clean draft with no delivery/response/attachment/conversion evidence (204),
825
+ * and refuses a protected draft with 409.
826
+ */
398
827
  async deleteQuote(id) {
399
- return this.request("DELETE", `/quotes/${encodeURIComponent(id)}`);
828
+ return this.requestUnwrapped("DELETE", `/quotes/${pathSegment(id)}`);
400
829
  }
401
830
  // ---------------------------------------------------------------- Vendors
402
831
  // ----------------------------------------------------------------
@@ -410,35 +839,96 @@ export class FrihetClient {
410
839
  });
411
840
  }
412
841
  async getVendor(id) {
413
- return this.requestUnwrapped("GET", `/vendors/${encodeURIComponent(id)}`);
842
+ return this.requestUnwrapped("GET", `/vendors/${pathSegment(id)}`);
414
843
  }
415
844
  async createVendor(data) {
416
845
  return this.requestUnwrapped("POST", "/vendors", data);
417
846
  }
418
847
  async updateVendor(id, data) {
419
- return this.requestUnwrapped("PATCH", `/vendors/${encodeURIComponent(id)}`, data);
848
+ return this.requestUnwrapped("PATCH", `/vendors/${pathSegment(id)}`, data);
420
849
  }
421
850
  async deleteVendor(id) {
422
- return this.request("DELETE", `/vendors/${encodeURIComponent(id)}`);
851
+ return this.request("DELETE", `/vendors/${pathSegment(id)}`);
423
852
  }
424
853
  // ---------------------------------------------------------------- Invoice Actions
425
854
  // ----------------------------------------------------------------
426
855
  async sendInvoice(id, to) {
427
- return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/send`, to ? { to } : undefined);
856
+ return this.requestUnwrapped("POST", `/invoices/${pathSegment(id)}/send`, to ? { recipientEmail: to } : undefined);
428
857
  }
429
858
  async markInvoicePaid(id, paidDate) {
430
- return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/paid`, paidDate ? { paidDate } : undefined);
859
+ return this.requestUnwrapped("POST", `/invoices/${pathSegment(id)}/paid`, paidDate ? { paidDate } : undefined);
431
860
  }
432
861
  async getInvoicePdf(id) {
433
- // get_invoice_pdf's outputSchema (pdfResultOutput) expects the flat
434
- // { id, url?, contentType? } item, same as every other single-object read.
435
- return this.requestUnwrapped("GET", `/invoices/${encodeURIComponent(id)}/pdf`);
862
+ // One fetch, one Response: validate MIME and signature only after the
863
+ // bounded stream completes. The ERP endpoint has always returned raw bytes
864
+ // (functions/src/publicApi.ts:6149-6167); there is no pre-signed-URL or JSON
865
+ // success contract to emulate.
866
+ const url = `/invoices/${pathSegment(id)}/pdf`;
867
+ const doc = await this.requestDocument("GET", url, undefined, undefined, undefined, (contentType) => {
868
+ const norm = normalizedContentType(contentType);
869
+ return norm === "application/pdf"
870
+ || norm === "application/octet-stream"
871
+ || norm === "binary/octet-stream"
872
+ ? MAX_PDF_BYTES
873
+ : MAX_ERROR_BYTES;
874
+ });
875
+ const normType = normalizedContentType(doc.contentType);
876
+ if (normType !== "application/pdf" && normType !== "application/octet-stream" && normType !== "binary/octet-stream") {
877
+ throw new FrihetApiError(200, "invalid_response", `Invoice PDF endpoint returned unexpected Content-Type: ${doc.contentType}`);
878
+ }
879
+ if (!hasPdfSignature(doc.bytes)) {
880
+ throw new FrihetApiError(200, "invalid_response", "Invoice PDF response is malformed");
881
+ }
882
+ if (doc.sizeBytes > MAX_PDF_BYTES) {
883
+ throw new FrihetApiError(413, "payload_too_large", `Invoice PDF exceeds maximum allowed size of ${MAX_PDF_BYTES} bytes`);
884
+ }
885
+ return {
886
+ id,
887
+ contentType: "application/pdf",
888
+ sizeBytes: doc.sizeBytes,
889
+ base64: encodeBase64(doc.bytes),
890
+ ...(doc.filename ? { filename: doc.filename } : {}),
891
+ };
436
892
  }
437
893
  async getInvoiceEInvoice(invoiceId) {
438
- // get_invoice_einvoice's outputSchema expects the flat { xml, filename, format }
439
- // item. requestUnwrapped is a no-op if the body isn't an object envelope
440
- // (e.g. a bare XML string), so this is safe either way.
441
- return this.requestUnwrapped("GET", `/invoices/${encodeURIComponent(invoiceId)}/xml`);
894
+ // Stored e-invoices are either XML or Factur-X PDF. Select the size cap
895
+ // from the actual MIME before reading and preserve the request identity.
896
+ const url = `/invoices/${pathSegment(invoiceId)}/xml`;
897
+ const doc = await this.requestDocument("GET", url, undefined, undefined, undefined, (contentType) => {
898
+ const normalized = normalizedContentType(contentType);
899
+ if (normalized === "application/pdf")
900
+ return MAX_PDF_BYTES;
901
+ if (isXmlContentType(contentType))
902
+ return MAX_XML_BYTES;
903
+ return MAX_ERROR_BYTES;
904
+ });
905
+ const normalized = normalizedContentType(doc.contentType);
906
+ if (normalized === "application/pdf") {
907
+ if (!hasPdfSignature(doc.bytes)) {
908
+ throw new FrihetApiError(200, "invalid_response", "Factur-X PDF response is malformed");
909
+ }
910
+ return {
911
+ id: invoiceId,
912
+ contentType: doc.contentType,
913
+ sizeBytes: doc.sizeBytes,
914
+ base64: encodeBase64(doc.bytes),
915
+ ...(doc.filename ? { filename: doc.filename } : {}),
916
+ };
917
+ }
918
+ if (!isXmlContentType(doc.contentType)) {
919
+ throw new FrihetApiError(200, "invalid_response", `E-invoice endpoint returned unexpected Content-Type: ${doc.contentType}`);
920
+ }
921
+ const xmlText = decodeUtf8(doc.bytes, "E-invoice XML response");
922
+ if (!xmlText.trimStart().startsWith("<")) {
923
+ throw new FrihetApiError(200, "invalid_response", "E-invoice XML response is malformed");
924
+ }
925
+ return {
926
+ id: invoiceId,
927
+ contentType: doc.contentType,
928
+ xml: xmlText,
929
+ sizeBytes: doc.sizeBytes,
930
+ ...(doc.filename ? { filename: doc.filename } : {}),
931
+ };
442
932
  }
443
933
  /**
444
934
  * `POST /v1/invoices/:id/credit-note` — creates a rectificativa DRAFT.
@@ -453,74 +943,117 @@ export class FrihetClient {
453
943
  * which is the property that matters here.
454
944
  */
455
945
  async createCreditNote(invoiceId, data, idempotencyKey) {
456
- return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(invoiceId)}/credit-note`, data, undefined, idempotencyKey);
946
+ return this.requestUnwrapped("POST", `/invoices/${pathSegment(invoiceId)}/credit-note`, data, undefined, idempotencyKey);
457
947
  }
458
948
  async applyLateFee(invoiceId, data) {
459
- return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(invoiceId)}/late-fee`, data ?? {});
949
+ return this.requestUnwrapped("POST", `/invoices/${pathSegment(invoiceId)}/late-fee`, data ?? {});
460
950
  }
461
951
  // ---------------------------------------------------------------- Quote Actions
462
952
  // ----------------------------------------------------------------
463
953
  async sendQuote(id, to) {
464
- return this.requestUnwrapped("POST", `/quotes/${encodeURIComponent(id)}/send`, to ? { to } : undefined);
954
+ return this.requestUnwrapped("POST", `/quotes/${pathSegment(id)}/send`, to ? { recipientEmail: to } : undefined);
465
955
  }
466
956
  // ---------------------------------------------------------------- Webhooks
467
957
  // ----------------------------------------------------------------
468
- async listWebhooks(params) {
469
- return this.requestPaginated("GET", "/webhooks", undefined, {
470
- limit: params?.limit,
471
- offset: params?.offset,
472
- });
958
+ async listWebhooks() {
959
+ const result = await this.request("GET", "/webhooks");
960
+ if (result === null ||
961
+ typeof result !== "object" ||
962
+ !("data" in result) ||
963
+ !("total" in result) ||
964
+ !Array.isArray(result.data) ||
965
+ typeof result.total !== "number") {
966
+ throw new FrihetApiError(200, "invalid_response", "API returned invalid webhook list response");
967
+ }
968
+ // `/webhooks` is deliberately unpaginated. Keep its `{ data, total }`
969
+ // contract and discard only the transport-level `meta` sibling.
970
+ return {
971
+ data: result.data.map((webhook) => normalizeWebhookRecord(webhook)),
972
+ total: result.total,
973
+ };
473
974
  }
474
975
  async getWebhook(id) {
475
- return this.requestUnwrapped("GET", `/webhooks/${encodeURIComponent(id)}`);
976
+ const result = await this.requestUnwrapped("GET", `/webhooks/${pathSegment(id)}`);
977
+ return normalizeWebhookRecord(result);
476
978
  }
477
979
  async createWebhook(data) {
478
- return this.requestUnwrapped("POST", "/webhooks", data);
980
+ const result = await this.requestUnwrapped("POST", "/webhooks", data);
981
+ return normalizeWebhookRecord(result, typeof data.secret === "string" ? data.secret : undefined);
479
982
  }
480
983
  async updateWebhook(id, data) {
481
- return this.requestUnwrapped("PATCH", `/webhooks/${encodeURIComponent(id)}`, data);
984
+ const result = await this.requestUnwrapped("PATCH", `/webhooks/${pathSegment(id)}`, data);
985
+ return normalizeWebhookRecord(result);
482
986
  }
483
987
  async deleteWebhook(id) {
484
- return this.request("DELETE", `/webhooks/${encodeURIComponent(id)}`);
988
+ return this.request("DELETE", `/webhooks/${pathSegment(id)}`);
989
+ }
990
+ // ---------------------------------------------------------------- Cross-resource search
991
+ // ----------------------------------------------------------------
992
+ //
993
+ // GET /v1/search/global?q=<term>&types=a,b,c&limit=&offset= (publicApi.ts:4476)
994
+ //
995
+ // Read-only fan-out across invoices / expenses / vendors / clients / products.
996
+ // All five collections are workspace-scoped via users/{userId}/..., so the
997
+ // tenant boundary is preserved by Firestore subcollection paths — same as
998
+ // every other list/search endpoint on the server. No mutation counterpart;
999
+ // no auth boundary different from any other read tool.
1000
+ async globalSearch(params) {
1001
+ const typesCsv = params.types && params.types.length > 0 ? params.types.join(",") : undefined;
1002
+ const result = await this.request("GET", "/search/global", undefined, {
1003
+ q: params.q,
1004
+ types: typesCsv,
1005
+ limit: params.limit,
1006
+ offset: params.offset,
1007
+ });
1008
+ return {
1009
+ data: result.data,
1010
+ total: result.total,
1011
+ limit: result.limit,
1012
+ offset: result.offset,
1013
+ hasMore: result.hasMore,
1014
+ query: result.query,
1015
+ types: result.types,
1016
+ ...(result.truncated !== undefined ? { truncated: result.truncated } : {}),
1017
+ };
485
1018
  }
486
1019
  // ---------------------------------------------------------------- CRM: Contacts
487
1020
  // ----------------------------------------------------------------
488
1021
  async listClientContacts(clientId, params) {
489
- return this.requestPaginated("GET", `/clients/${encodeURIComponent(clientId)}/contacts`, undefined, {
1022
+ return this.requestPaginated("GET", `/clients/${pathSegment(clientId)}/contacts`, undefined, {
490
1023
  limit: params?.limit,
491
1024
  offset: params?.offset,
492
1025
  });
493
1026
  }
494
1027
  async createClientContact(clientId, data) {
495
- return this.requestUnwrapped("POST", `/clients/${encodeURIComponent(clientId)}/contacts`, data);
1028
+ return this.requestUnwrapped("POST", `/clients/${pathSegment(clientId)}/contacts`, data);
496
1029
  }
497
1030
  async deleteClientContact(clientId, contactId) {
498
- return this.request("DELETE", `/clients/${encodeURIComponent(clientId)}/contacts/${encodeURIComponent(contactId)}`);
1031
+ return this.request("DELETE", `/clients/${pathSegment(clientId)}/contacts/${pathSegment(contactId)}`);
499
1032
  }
500
1033
  // ---------------------------------------------------------------- CRM: Activities
501
1034
  // ----------------------------------------------------------------
502
1035
  async listClientActivities(clientId, params) {
503
- return this.requestPaginated("GET", `/clients/${encodeURIComponent(clientId)}/activities`, undefined, {
1036
+ return this.requestPaginated("GET", `/clients/${pathSegment(clientId)}/activities`, undefined, {
504
1037
  limit: params?.limit,
505
1038
  offset: params?.offset,
506
1039
  });
507
1040
  }
508
1041
  async logClientActivity(clientId, data) {
509
- return this.requestUnwrapped("POST", `/clients/${encodeURIComponent(clientId)}/activities`, data);
1042
+ return this.requestUnwrapped("POST", `/clients/${pathSegment(clientId)}/activities`, data);
510
1043
  }
511
1044
  // ---------------------------------------------------------------- CRM: Notes
512
1045
  // ----------------------------------------------------------------
513
1046
  async listClientNotes(clientId, params) {
514
- return this.requestPaginated("GET", `/clients/${encodeURIComponent(clientId)}/notes`, undefined, {
1047
+ return this.requestPaginated("GET", `/clients/${pathSegment(clientId)}/notes`, undefined, {
515
1048
  limit: params?.limit,
516
1049
  offset: params?.offset,
517
1050
  });
518
1051
  }
519
1052
  async createClientNote(clientId, data) {
520
- return this.requestUnwrapped("POST", `/clients/${encodeURIComponent(clientId)}/notes`, data);
1053
+ return this.requestUnwrapped("POST", `/clients/${pathSegment(clientId)}/notes`, data);
521
1054
  }
522
1055
  async deleteClientNote(clientId, noteId) {
523
- return this.request("DELETE", `/clients/${encodeURIComponent(clientId)}/notes/${encodeURIComponent(noteId)}`);
1056
+ return this.request("DELETE", `/clients/${pathSegment(clientId)}/notes/${pathSegment(noteId)}`);
524
1057
  }
525
1058
  // ---------------------------------------------------------------- Deposits
526
1059
  // ----------------------------------------------------------------
@@ -537,22 +1070,22 @@ export class FrihetClient {
537
1070
  });
538
1071
  }
539
1072
  async getDeposit(id) {
540
- return this.requestUnwrapped("GET", `/deposits/${encodeURIComponent(id)}`);
1073
+ return this.requestUnwrapped("GET", `/deposits/${pathSegment(id)}`);
541
1074
  }
542
1075
  async createDeposit(data) {
543
- return this.requestUnwrapped("POST", "/deposits", data);
1076
+ return this.requestUnwrapped("POST", "/deposits", normalizeDepositBody(data, false));
544
1077
  }
545
1078
  async updateDeposit(id, data) {
546
- return this.requestUnwrapped("PATCH", `/deposits/${encodeURIComponent(id)}`, data);
1079
+ return this.requestUnwrapped("PATCH", `/deposits/${pathSegment(id)}`, normalizeDepositBody(data, true));
547
1080
  }
548
1081
  async deleteDeposit(id) {
549
- return this.request("DELETE", `/deposits/${encodeURIComponent(id)}`);
1082
+ return this.request("DELETE", `/deposits/${pathSegment(id)}`);
550
1083
  }
551
1084
  async applyDeposit(id, data) {
552
- return this.requestUnwrapped("POST", `/deposits/${encodeURIComponent(id)}/apply`, data ?? {});
1085
+ return this.requestUnwrapped("POST", `/deposits/${pathSegment(id)}/apply`, normalizeApplyDepositBody(data));
553
1086
  }
554
1087
  async refundDeposit(id, data) {
555
- return this.requestUnwrapped("POST", `/deposits/${encodeURIComponent(id)}/refund`, data ?? {});
1088
+ return this.requestUnwrapped("POST", `/deposits/${pathSegment(id)}/refund`, data ?? {});
556
1089
  }
557
1090
  // ---------------------------------------------------------------- E-Invoicing
558
1091
  // ----------------------------------------------------------------
@@ -563,7 +1096,7 @@ export class FrihetClient {
563
1096
  // get_einvoice_status's outputSchema (eInvoiceStatusOutput) expects the
564
1097
  // flat status object, same single-object envelope convention as the rest
565
1098
  // of the /v1 REST surface.
566
- return this.requestUnwrapped("GET", `/einvoice/status/${encodeURIComponent(workflowRunId)}`);
1099
+ return this.requestUnwrapped("GET", `/einvoice/status/${pathSegment(workflowRunId)}`);
567
1100
  }
568
1101
  async validateEInvoiceXml(params) {
569
1102
  return this.requestUnwrapped("POST", "/einvoice/validate", params);
@@ -576,22 +1109,26 @@ export class FrihetClient {
576
1109
  // New per-invoice endpoints: /v1/invoices/:id/einvoice/export, /face/*, /ticketbai/*
577
1110
  // 404 responses propagate to tool handlers which fall back to stub responses.
578
1111
  async exportEInvoice(params) {
579
- const { invoiceId, ...body } = params;
580
- return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(invoiceId)}/einvoice/export`, body);
1112
+ const { invoiceId, format, signed } = params;
1113
+ const body = {
1114
+ format: toApiEInvoiceExportFormat(format),
1115
+ ...(signed === undefined ? {} : { signed }),
1116
+ };
1117
+ return this.requestUnwrapped("POST", `/invoices/${pathSegment(invoiceId)}/einvoice/export`, body);
581
1118
  }
582
1119
  async faceSubmit(params) {
583
1120
  const { invoiceId, mode } = params;
584
- return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(invoiceId)}/face/submit`, { mode });
1121
+ return this.requestUnwrapped("POST", `/invoices/${pathSegment(invoiceId)}/face/submit`, { mode });
585
1122
  }
586
1123
  async faceStatus(params) {
587
- return this.request("GET", `/invoices/${encodeURIComponent(params.invoiceId)}/face/status`);
1124
+ return this.request("GET", `/invoices/${pathSegment(params.invoiceId)}/face/status`);
588
1125
  }
589
1126
  async ticketbaiSubmit(params) {
590
1127
  const { invoiceId, sandbox } = params;
591
- return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(invoiceId)}/ticketbai/submit`, { sandbox });
1128
+ return this.requestUnwrapped("POST", `/invoices/${pathSegment(invoiceId)}/ticketbai/submit`, { sandbox });
592
1129
  }
593
1130
  async ticketbaiStatus(params) {
594
- return this.request("GET", `/invoices/${encodeURIComponent(params.invoiceId)}/ticketbai/status`);
1131
+ return this.request("GET", `/invoices/${pathSegment(params.invoiceId)}/ticketbai/status`);
595
1132
  }
596
1133
  // kSeFSubmit intentionally omitted — ksef_submit tool is always-stub (public KSeF endpoint not yet exposed; transport infra-ready in Frihet-ERP).
597
1134
  // ---------------------------------------------------------------- Stay (Vacation Rental)
@@ -612,7 +1149,7 @@ export class FrihetClient {
612
1149
  });
613
1150
  }
614
1151
  async getReservation(id) {
615
- return this.requestUnwrapped("GET", `/stay/reservations/${encodeURIComponent(id)}`);
1152
+ return this.requestUnwrapped("GET", `/stay/reservations/${pathSegment(id)}`);
616
1153
  }
617
1154
  async createReservation(data) {
618
1155
  return this.requestUnwrapped("POST", "/stay/reservations", data);
@@ -628,7 +1165,7 @@ export class FrihetClient {
628
1165
  });
629
1166
  }
630
1167
  async syncChannel(channelId, direction) {
631
- return this.requestUnwrapped("POST", `/stay/channels/${encodeURIComponent(channelId)}/sync`, { direction });
1168
+ return this.requestUnwrapped("POST", `/stay/channels/${pathSegment(channelId)}/sync`, { direction });
632
1169
  }
633
1170
  // ---------------------------------------------------------------- POS (Point of Sale)
634
1171
  // ----------------------------------------------------------------
@@ -641,7 +1178,7 @@ export class FrihetClient {
641
1178
  });
642
1179
  }
643
1180
  async getSale(id) {
644
- return this.requestUnwrapped("GET", `/pos/sales/${encodeURIComponent(id)}`);
1181
+ return this.requestUnwrapped("GET", `/pos/sales/${pathSegment(id)}`);
645
1182
  }
646
1183
  async listSales(params) {
647
1184
  return this.requestPaginated("GET", "/pos/sales", undefined, {
@@ -655,7 +1192,7 @@ export class FrihetClient {
655
1192
  });
656
1193
  }
657
1194
  async refundSale(id, data) {
658
- return this.requestUnwrapped("POST", `/pos/sales/${encodeURIComponent(id)}/refund`, data ?? {});
1195
+ return this.requestUnwrapped("POST", `/pos/sales/${pathSegment(id)}/refund`, data ?? {});
659
1196
  }
660
1197
  // ---------------------------------------------------------------- Kitchen (KDS)
661
1198
  // ----------------------------------------------------------------
@@ -670,10 +1207,13 @@ export class FrihetClient {
670
1207
  });
671
1208
  }
672
1209
  async getKitchenTicket(id) {
673
- return this.requestUnwrapped("GET", `/kitchen/tickets/${encodeURIComponent(id)}`);
1210
+ return this.requestUnwrapped("GET", `/kitchen/tickets/${pathSegment(id)}`);
674
1211
  }
675
1212
  async updateKitchenTicket(id, data) {
676
- return this.requestUnwrapped("PATCH", `/kitchen/tickets/${encodeURIComponent(id)}`, data);
1213
+ // The public tool accepts one station; the strict REST contract stores an array.
1214
+ const { stationId, ...patch } = data;
1215
+ const body = stationId === undefined ? patch : { ...patch, stationIds: [stationId] };
1216
+ return this.requestUnwrapped("PATCH", `/kitchen/tickets/${pathSegment(id)}`, body);
677
1217
  }
678
1218
  async listKitchenStations(params) {
679
1219
  return this.requestPaginated("GET", "/kitchen/stations", undefined, {
@@ -716,7 +1256,7 @@ export class FrihetClient {
716
1256
  });
717
1257
  }
718
1258
  async getBankAccount(id) {
719
- return this.requestUnwrapped("GET", `/banking/accounts/${encodeURIComponent(id)}`);
1259
+ return this.requestUnwrapped("GET", `/banking/accounts/${pathSegment(id)}`);
720
1260
  }
721
1261
  async listTransactions(params) {
722
1262
  return this.requestPaginated("GET", "/banking/transactions", undefined, {
@@ -731,10 +1271,10 @@ export class FrihetClient {
731
1271
  });
732
1272
  }
733
1273
  async categorizeTransaction(id, data) {
734
- return this.requestUnwrapped("PATCH", `/banking/transactions/${encodeURIComponent(id)}/categorize`, data);
1274
+ return this.requestUnwrapped("PATCH", `/banking/transactions/${pathSegment(id)}/categorize`, data);
735
1275
  }
736
1276
  async matchTransactionToDocument(transactionId, data) {
737
- return this.requestUnwrapped("POST", `/banking/transactions/${encodeURIComponent(transactionId)}/match`, data);
1277
+ return this.requestUnwrapped("POST", `/banking/transactions/${pathSegment(transactionId)}/match`, data);
738
1278
  }
739
1279
  // ---------------------------------------------------------------- Fiscal
740
1280
  // RESOLVED (was: "planned — 404 propagates until backend ships"). The
@@ -746,18 +1286,19 @@ export class FrihetClient {
746
1286
  // NOTE: /v1/fiscal/verifactu/* + /ticketbai/* below remain pending and 404
747
1287
  // until those backends ship (they keep the raw `request` path for now).
748
1288
  async getFiscalModeloSummary(modeloCode, period) {
749
- return this.requestUnwrapped("GET", `/fiscal/modelo/${encodeURIComponent(modeloCode)}`, undefined, {
750
- period,
751
- });
1289
+ // The backend reads `?quarter=` (303/130) or `?year=` (390/347) and
1290
+ // silently answers the CURRENT period for any other param, so the param
1291
+ // name comes from the shared rule table, never a generic `?period=`.
1292
+ return this.requestUnwrapped("GET", `/fiscal/modelo/${pathSegment(modeloCode)}`, undefined, fiscalModeloQuery(modeloCode, period));
752
1293
  }
753
1294
  async getVerifactuStatus(invoiceId) {
754
- return this.requestUnwrapped("GET", `/fiscal/verifactu/${encodeURIComponent(invoiceId)}/status`);
1295
+ return this.requestUnwrapped("GET", `/fiscal/verifactu/${pathSegment(invoiceId)}/status`);
755
1296
  }
756
1297
  async resubmitVerifactu(invoiceId) {
757
- return this.requestUnwrapped("POST", `/fiscal/verifactu/${encodeURIComponent(invoiceId)}/resubmit`, {});
1298
+ return this.requestUnwrapped("POST", `/fiscal/verifactu/${pathSegment(invoiceId)}/resubmit`, {});
758
1299
  }
759
1300
  async getTicketbaiStatus(invoiceId) {
760
- return this.request("GET", `/fiscal/ticketbai/${encodeURIComponent(invoiceId)}/status`);
1301
+ return this.request("GET", `/fiscal/ticketbai/${pathSegment(invoiceId)}/status`);
761
1302
  }
762
1303
  // ---------------------------------------------------------------- Time Tracking
763
1304
  // Backend: /v1/time/* endpoints live as of Wave 4-A (Frihet-ERP functions/src/publicApi.ts).
@@ -774,16 +1315,16 @@ export class FrihetClient {
774
1315
  });
775
1316
  }
776
1317
  async getTimeEntry(id) {
777
- return this.requestUnwrapped("GET", `/time/entries/${encodeURIComponent(id)}`);
1318
+ return this.requestUnwrapped("GET", `/time/entries/${pathSegment(id)}`);
778
1319
  }
779
1320
  async createTimeEntry(data) {
780
1321
  return this.requestUnwrapped("POST", "/time/entries", data);
781
1322
  }
782
1323
  async updateTimeEntry(id, data) {
783
- return this.requestUnwrapped("PATCH", `/time/entries/${encodeURIComponent(id)}`, data);
1324
+ return this.requestUnwrapped("PATCH", `/time/entries/${pathSegment(id)}`, data);
784
1325
  }
785
1326
  async deleteTimeEntry(id) {
786
- return this.request("DELETE", `/time/entries/${encodeURIComponent(id)}`);
1327
+ return this.request("DELETE", `/time/entries/${pathSegment(id)}`);
787
1328
  }
788
1329
  async getTimeSummary(params) {
789
1330
  return this.requestUnwrapped("GET", "/time/summary", undefined, {
@@ -804,25 +1345,25 @@ export class FrihetClient {
804
1345
  });
805
1346
  }
806
1347
  async getRecurringInvoice(id) {
807
- return this.requestUnwrapped("GET", `/recurring/invoices/${encodeURIComponent(id)}`);
1348
+ return this.requestUnwrapped("GET", `/recurring/invoices/${pathSegment(id)}`);
808
1349
  }
809
1350
  async createRecurringInvoice(data) {
810
1351
  return this.requestUnwrapped("POST", "/recurring/invoices", data);
811
1352
  }
812
1353
  async updateRecurringInvoice(id, data) {
813
- return this.requestUnwrapped("PATCH", `/recurring/invoices/${encodeURIComponent(id)}`, data);
1354
+ return this.requestUnwrapped("PATCH", `/recurring/invoices/${pathSegment(id)}`, data);
814
1355
  }
815
1356
  async pauseRecurringInvoice(id) {
816
- return this.requestUnwrapped("POST", `/recurring/invoices/${encodeURIComponent(id)}/pause`, {});
1357
+ return this.requestUnwrapped("POST", `/recurring/invoices/${pathSegment(id)}/pause`, {});
817
1358
  }
818
1359
  async resumeRecurringInvoice(id) {
819
- return this.requestUnwrapped("POST", `/recurring/invoices/${encodeURIComponent(id)}/resume`, {});
1360
+ return this.requestUnwrapped("POST", `/recurring/invoices/${pathSegment(id)}/resume`, {});
820
1361
  }
821
1362
  async deleteRecurringInvoice(id) {
822
- return this.request("DELETE", `/recurring/invoices/${encodeURIComponent(id)}`);
1363
+ return this.request("DELETE", `/recurring/invoices/${pathSegment(id)}`);
823
1364
  }
824
1365
  async runRecurringNow(templateId, options) {
825
- return this.requestUnwrapped("POST", `/recurring/invoices/${encodeURIComponent(templateId)}/run`, {
1366
+ return this.requestUnwrapped("POST", `/recurring/invoices/${pathSegment(templateId)}/run`, {
826
1367
  draftOnly: options?.draftOnly ?? true,
827
1368
  });
828
1369
  }
@@ -840,10 +1381,10 @@ export class FrihetClient {
840
1381
  return this.requestUnwrapped("POST", "/team/members/invite", data);
841
1382
  }
842
1383
  async updateTeamMemberRole(memberId, role) {
843
- return this.requestUnwrapped("PATCH", `/team/members/${encodeURIComponent(memberId)}/role`, { role });
1384
+ return this.requestUnwrapped("PATCH", `/team/members/${pathSegment(memberId)}/role`, { role });
844
1385
  }
845
1386
  async removeTeamMember(memberId) {
846
- return this.request("DELETE", `/team/members/${encodeURIComponent(memberId)}`);
1387
+ return this.request("DELETE", `/team/members/${pathSegment(memberId)}`);
847
1388
  }
848
1389
  // ---------------------------------------------------------------- Gestoria (Wave Fase 1)
849
1390
  // Backend: /v1/gestoria/* endpoints land with Frihet-ERP Wave Fase 1 closure.
@@ -869,20 +1410,22 @@ export class FrihetClient {
869
1410
  return this.requestUnwrapped("POST", "/gestoria/templates/bulk-send", data);
870
1411
  }
871
1412
  async getGestoriaAgingConsolidated(params) {
872
- return this.request("GET", "/gestoria/aging/consolidated", undefined, {
873
- ownerUid: params?.ownerUid,
1413
+ return this.requestUnwrapped("POST", "/gestoria/aging", {
1414
+ workspaceIds: params?.workspaceIds,
1415
+ asOf: params?.asOf,
1416
+ bustCache: params?.bustCache,
874
1417
  });
875
1418
  }
876
1419
  // ---------------------------------------------------------------- Audit GL
877
1420
  // NOTE: /v1/gl/* endpoints proxy Firebase callables (PR #395). 404 until REST shell ships.
878
1421
  async approveGLEntry(entryId, notes) {
879
- return this.requestUnwrapped("POST", `/gl/entries/${encodeURIComponent(entryId)}/approve`, { notes });
1422
+ return this.requestUnwrapped("POST", `/gl/entries/${pathSegment(entryId)}/approve`, { notes });
880
1423
  }
881
- async rejectGLEntry(entryId, reason) {
882
- return this.requestUnwrapped("POST", `/gl/entries/${encodeURIComponent(entryId)}/reject`, { reason });
1424
+ async rejectGLEntry(entryId, note) {
1425
+ return this.requestUnwrapped("POST", `/gl/entries/${pathSegment(entryId)}/reject`, { note });
883
1426
  }
884
1427
  async getGLEntryAuditLog(entryId) {
885
- return this.request("GET", `/gl/entries/${encodeURIComponent(entryId)}/audit-log`);
1428
+ return this.request("GET", `/gl/entries/${pathSegment(entryId)}/audit-log`);
886
1429
  }
887
1430
  // ---------------------------------------------------------------- White-label Portal Domain
888
1431
  // NOTE: /v1/portal/domain/* endpoints proxy Firebase callables (PR #397). 404 until REST shell ships.
@@ -890,10 +1433,10 @@ export class FrihetClient {
890
1433
  return this.requestUnwrapped("POST", "/portal/domain", data);
891
1434
  }
892
1435
  async verifyCustomPortalDomain(data) {
893
- return this.requestUnwrapped("POST", `/portal/domain/${encodeURIComponent(data.domain)}/verify`, {});
1436
+ return this.requestUnwrapped("POST", `/portal/domain/${pathSegment(data.domain)}/verify`, {});
894
1437
  }
895
1438
  async removeCustomPortalDomain(data) {
896
- return this.request("DELETE", `/portal/domain/${encodeURIComponent(data.domain)}`);
1439
+ return this.request("DELETE", `/portal/domain/${pathSegment(data.domain)}`);
897
1440
  }
898
1441
  // ---------------------------------------------------------------- Self-onboard + VIES
899
1442
  // NOTE: /v1/portal/onboard/* endpoints proxy Firebase callables (PR #398). 404 until REST shell ships.
@@ -909,7 +1452,7 @@ export class FrihetClient {
909
1452
  // ---------------------------------------------------------------- IGIC (Canary Islands)
910
1453
  // NOTE: /v1/igic/* endpoints (PR #390). 404 until REST shell ships.
911
1454
  async getIgicModeloSummary(modeloCode, params) {
912
- return this.request("GET", `/igic/modelo/${encodeURIComponent(modeloCode)}`, undefined, {
1455
+ return this.request("GET", `/igic/modelo/${pathSegment(modeloCode)}`, undefined, {
913
1456
  year: params?.year,
914
1457
  period: params?.period,
915
1458
  });
@@ -920,7 +1463,7 @@ export class FrihetClient {
920
1463
  // ---------------------------------------------------------------- Impuesto Sociedades (IS)
921
1464
  // NOTE: /v1/is/* endpoints (PR #392). 404 until REST shell ships.
922
1465
  async getISSummary(modeloCode, params) {
923
- return this.request("GET", `/is/modelo/${encodeURIComponent(modeloCode)}`, undefined, {
1466
+ return this.request("GET", `/is/modelo/${pathSegment(modeloCode)}`, undefined, {
924
1467
  year: params?.year,
925
1468
  installment: params?.installment,
926
1469
  });
@@ -928,7 +1471,7 @@ export class FrihetClient {
928
1471
  // ---------------------------------------------------------------- Bank Rules
929
1472
  // NOTE: /v1/banking/rules Q3-flagged (PR #394). 404 until callable wrapper ships.
930
1473
  async listBankRules(params) {
931
- return this.request("GET", "/banking/rules", undefined, {
1474
+ return this.requestPaginated("GET", "/banking/rules", undefined, {
932
1475
  isActive: params?.isActive !== undefined ? String(params.isActive) : undefined,
933
1476
  limit: params?.limit,
934
1477
  offset: params?.offset,
@@ -938,7 +1481,8 @@ export class FrihetClient {
938
1481
  return this.requestUnwrapped("POST", "/banking/rules", data);
939
1482
  }
940
1483
  // ---------------------------------------------------------------- HR (Leaves + Attendance + Anomalies)
941
- // NOTE: /v1/leaves, /v1/time-entries, /v1/anomalies — D4-A parallel deploy. 404 propagates until backend ships.
1484
+ // The ERP routes are live. Single-object family reports use the standard
1485
+ // `{ data, meta }` response envelope and must be unwrapped exactly once.
942
1486
  async listLeaves(params) {
943
1487
  return this.requestPaginated("GET", "/leaves", undefined, {
944
1488
  employeeId: params?.employeeId,
@@ -954,22 +1498,22 @@ export class FrihetClient {
954
1498
  return this.requestUnwrapped("POST", "/leaves", data);
955
1499
  }
956
1500
  async approveLeave(leaveId, data) {
957
- return this.requestUnwrapped("POST", `/leaves/${encodeURIComponent(leaveId)}/approve`, data ?? {});
1501
+ return this.requestUnwrapped("POST", `/leaves/${pathSegment(leaveId)}/approve`, data ?? {});
958
1502
  }
959
1503
  async rejectLeave(leaveId, data) {
960
- return this.requestUnwrapped("POST", `/leaves/${encodeURIComponent(leaveId)}/reject`, data);
1504
+ return this.requestUnwrapped("POST", `/leaves/${pathSegment(leaveId)}/reject`, data);
961
1505
  }
962
1506
  async cancelLeave(leaveId) {
963
- return this.requestUnwrapped("POST", `/leaves/${encodeURIComponent(leaveId)}/cancel`, {});
1507
+ return this.requestUnwrapped("POST", `/leaves/${pathSegment(leaveId)}/cancel`, {});
964
1508
  }
965
- async attendanceClockIn(data) {
966
- return this.requestUnwrapped("POST", "/time-entries/clock-in", data);
1509
+ async attendanceClockIn(data, idempotencyKey) {
1510
+ return this.requestUnwrapped("POST", "/time-entries/clock-in", data, undefined, idempotencyKey);
967
1511
  }
968
- async attendanceClockOut(entryId) {
969
- return this.requestUnwrapped("PATCH", `/time-entries/${encodeURIComponent(entryId)}/clock-out`, {});
1512
+ async attendanceClockOut(entryId, idempotencyKey) {
1513
+ return this.requestUnwrapped("PATCH", `/time-entries/${pathSegment(entryId)}/clock-out`, {}, undefined, idempotencyKey);
970
1514
  }
971
1515
  async getOvertimeReport(params) {
972
- return this.request("GET", "/time-entries/overtime", undefined, {
1516
+ return this.requestUnwrapped("GET", "/time-entries/overtime", undefined, {
973
1517
  period: params.period,
974
1518
  employeeId: params.employeeId,
975
1519
  });
@@ -987,50 +1531,49 @@ export class FrihetClient {
987
1531
  // ---------------------------------------------------------------- Webhook Trust-Area Extensions
988
1532
  // NOTE: /v1/webhooks/:id/test — D4-A parallel deploy. 404 propagates until backend ships.
989
1533
  async testWebhook(id, data) {
990
- return this.requestUnwrapped("POST", `/webhooks/${encodeURIComponent(id)}/test`, data ?? {});
1534
+ return this.requestUnwrapped("POST", `/webhooks/${pathSegment(id)}/test`, data ?? {});
991
1535
  }
992
1536
  // ---------------------------------------------------------------- Payroll
993
- // NOTE: /v1/payroll/prep/* — D4-A parallel deploy. 404 propagates until backend ships.
1537
+ // These live read routes return normalized payroll-preparation records in
1538
+ // the standard single-object `{ data, meta }` envelope.
994
1539
  async exportPayroll(params) {
995
- return this.request("GET", "/payroll/prep/export", undefined, {
1540
+ return this.requestUnwrapped("GET", "/payroll/prep/export", undefined, {
996
1541
  format: params.format,
997
1542
  month: params.month,
998
1543
  });
999
1544
  }
1000
1545
  async getPayrollChecklist(params) {
1001
- return this.request("GET", "/payroll/prep/employees", undefined, {
1546
+ return this.requestUnwrapped("GET", "/payroll/prep/employees", undefined, {
1002
1547
  month: params.month,
1003
1548
  });
1004
1549
  }
1005
- // ---------------------------------------------------------------- Onboarding
1006
- // NOTE: /v1/onboarding/* — D4-A parallel deploy. 404 propagates until backend ships.
1007
- async getOnboardingStatus() {
1008
- return this.request("GET", "/onboarding/status");
1009
- }
1010
- async setOnboardingPersona(data) {
1011
- return this.requestUnwrapped("PATCH", "/onboarding/persona", data);
1012
- }
1013
1550
  // ---------------------------------------------------------------- Permissions
1014
- // NOTE: /v1/permissions/* — D4-A parallel deploy. 404 propagates until backend ships.
1551
+ // These are standard ERP family responses: unwrap their single-object
1552
+ // `{ data, meta }` envelope before handing the payload to MCP output schemas.
1553
+ //
1554
+ // Note: /v1/onboarding/* methods (getOnboardingStatus, setOnboardingPersona)
1555
+ // were removed when the onboarding family was withdrawn via issue #124.
1556
+ // See src/tools/igic.ts for the rationale and the inline-stub precedent.
1015
1557
  async getPermissionsMatrix() {
1016
- return this.request("GET", "/permissions/matrix");
1558
+ return this.requestUnwrapped("GET", "/permissions/matrix");
1017
1559
  }
1018
1560
  async getMyPermissions() {
1019
- return this.request("GET", "/permissions/me");
1561
+ return this.requestUnwrapped("GET", "/permissions/me");
1020
1562
  }
1021
1563
  // ---------------------------------------------------------------- Period Close
1022
- // NOTE: /v1/periods/* — D4-A parallel deploy. 404 propagates until backend ships.
1564
+ // Period reads are live and enveloped. Fiscal close/reopen writes remain
1565
+ // honest ERP 501 responses and are intentionally unchanged here.
1023
1566
  async getCurrentPeriod(params) {
1024
- if (params?.periodId) {
1025
- return this.request("GET", `/periods/${encodeURIComponent(params.periodId)}`);
1567
+ if (params?.fiscalYear) {
1568
+ return this.requestUnwrapped("GET", `/periods/${pathSegment(params.fiscalYear)}`);
1026
1569
  }
1027
- return this.request("GET", "/periods/current");
1570
+ return this.requestUnwrapped("GET", "/periods/current");
1028
1571
  }
1029
1572
  async closePeriod(data) {
1030
1573
  return this.requestUnwrapped("POST", "/periods/close", data);
1031
1574
  }
1032
1575
  async reopenPeriod(data) {
1033
- return this.requestUnwrapped("POST", `/periods/${encodeURIComponent(data.periodId)}/reopen`, { reason: data.reason });
1576
+ return this.requestUnwrapped("POST", `/periods/${pathSegment(data.periodId)}/reopen`, { reason: data.reason });
1034
1577
  }
1035
1578
  }
1036
1579
  //# sourceMappingURL=client.js.map