@frihet/mcp-server 1.16.5 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -31
- package/dist/agent-onboarding.d.ts +98 -0
- package/dist/agent-onboarding.js +275 -0
- package/dist/api-origin.d.ts +9 -0
- package/dist/api-origin.js +41 -0
- package/dist/canary/mcp-harness.d.ts +77 -0
- package/dist/canary/mcp-harness.js +549 -0
- package/dist/capability-truth.d.ts +29 -0
- package/dist/capability-truth.js +254 -0
- package/dist/client-interface.d.ts +22 -14
- package/dist/client.d.ts +114 -18
- package/dist/client.js +520 -58
- package/dist/demo-client.d.ts +16 -13
- package/dist/demo-client.js +209 -29
- package/dist/demo-fixtures.d.ts +3 -3
- package/dist/demo-fixtures.js +7 -7
- package/dist/fiscal-aliases.d.ts +44 -0
- package/dist/fiscal-aliases.js +62 -0
- package/dist/index.js +41 -56
- package/dist/logger.d.ts +2 -2
- package/dist/logger.js +140 -8
- package/dist/observability.d.ts +18 -29
- package/dist/observability.js +192 -105
- package/dist/openai-profile.d.ts +27 -15
- package/dist/openai-profile.js +1191 -145
- package/dist/openai-review-contract.d.ts +49 -0
- package/dist/openai-review-contract.js +919 -0
- package/dist/openai-review-oauth.d.ts +98 -0
- package/dist/openai-review-oauth.js +136 -0
- package/dist/prompts/register-all.d.ts +2 -0
- package/dist/prompts/register-all.js +2 -0
- package/dist/public-capability-contract.d.ts +39 -0
- package/dist/public-capability-contract.js +186 -0
- package/dist/redaction.d.ts +10 -0
- package/dist/redaction.js +74 -0
- package/dist/resources/register-all.js +6 -12
- package/dist/server-composition.d.ts +16 -0
- package/dist/server-composition.js +44 -0
- package/dist/tool-exposure.d.ts +25 -22
- package/dist/tool-exposure.js +199 -77
- package/dist/tools/accountingClose.d.ts +14 -5
- package/dist/tools/accountingClose.js +41 -23
- package/dist/tools/backend-availability.d.ts +3 -3
- package/dist/tools/backend-availability.js +3 -3
- package/dist/tools/clients.js +9 -9
- package/dist/tools/crm.js +22 -21
- package/dist/tools/deposits.js +30 -11
- package/dist/tools/einvoice.d.ts +1 -1
- package/dist/tools/expenses.d.ts +16 -0
- package/dist/tools/expenses.js +47 -27
- package/dist/tools/hr.d.ts +2 -3
- package/dist/tools/hr.js +8 -9
- package/dist/tools/intelligence.js +95 -4
- package/dist/tools/invoices.js +150 -47
- package/dist/tools/payroll.d.ts +5 -8
- package/dist/tools/payroll.js +19 -32
- package/dist/tools/permissions.d.ts +4 -7
- package/dist/tools/permissions.js +11 -14
- package/dist/tools/products.js +6 -6
- package/dist/tools/quotes.js +109 -24
- package/dist/tools/shared.d.ts +1431 -75
- package/dist/tools/shared.js +386 -76
- package/dist/tools/team.js +18 -14
- package/dist/tools/vendors.js +9 -9
- package/dist/tools/webhooks.js +35 -30
- package/dist/types.d.ts +28 -4
- package/docs/agent-onboarding.json +381 -0
- package/package.json +32 -7
package/dist/client.js
CHANGED
|
@@ -11,11 +11,34 @@
|
|
|
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 { sanitizeServerRemediation } from "./redaction.js";
|
|
14
15
|
import { logApiCall, logRetry } from "./logger.js";
|
|
15
16
|
const BASE_URL = "https://api.frihet.io/v1";
|
|
17
|
+
const OAUTH_CLOUD_FUNCTION_BASE_URL = "https://europe-west1-gen-lang-client-0335716041.cloudfunctions.net/publicApi/api/v1";
|
|
18
|
+
const OAUTH_SERVICE_TRUSTED_BASE_URLS = new Set([
|
|
19
|
+
BASE_URL,
|
|
20
|
+
OAUTH_CLOUD_FUNCTION_BASE_URL,
|
|
21
|
+
]);
|
|
16
22
|
const MAX_RETRIES = 3;
|
|
17
23
|
const DEFAULT_RETRY_DELAY_MS = 1000;
|
|
18
24
|
const REQUEST_TIMEOUT_MS = 30000;
|
|
25
|
+
/**
|
|
26
|
+
* Hard size caps for document responses. Enforced TWICE: precheck on
|
|
27
|
+
* `Content-Length` (so an honest server doesn't waste bandwidth) and after
|
|
28
|
+
* streaming (so a missing/lying `Content-Length` still can't trigger an
|
|
29
|
+
* unbounded allocation).
|
|
30
|
+
*
|
|
31
|
+
* - PDF: 25 MiB — generous for any ERP-issued invoice PDF, including
|
|
32
|
+
* embedded logos, Facturae XML attachments, and stamp signatures.
|
|
33
|
+
* - XML: 5 MiB — UBL / Facturae / PEPPOL documents stay well under 1 MiB
|
|
34
|
+
* in practice; 5 MiB absorbs any historical / annex-laden outlier.
|
|
35
|
+
*
|
|
36
|
+
* Anything larger is rejected with `413 payload_too_large` BEFORE we allocate
|
|
37
|
+
* — the user sees a clean error, the worker doesn't OOM.
|
|
38
|
+
*/
|
|
39
|
+
export const MAX_PDF_BYTES = 25 * 1024 * 1024;
|
|
40
|
+
export const MAX_XML_BYTES = 5 * 1024 * 1024;
|
|
41
|
+
const MAX_ERROR_BYTES = 64 * 1024;
|
|
19
42
|
/**
|
|
20
43
|
* HTTP methods for which the backend treats the request as a mutation and
|
|
21
44
|
* therefore accepts (and for some endpoints REQUIRES) an `Idempotency-Key`
|
|
@@ -24,15 +47,42 @@ const REQUEST_TIMEOUT_MS = 30000;
|
|
|
24
47
|
* of the time — see src/__tests__/idempotency-key-contract.test.ts.
|
|
25
48
|
*/
|
|
26
49
|
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
50
|
+
/**
|
|
51
|
+
* Origin marker sent on every request so the backend can tell an MCP-driven
|
|
52
|
+
* create apart from a direct-API one.
|
|
53
|
+
*
|
|
54
|
+
* The backend classifier (`detectApiInvoiceSource`, Frihet-ERP
|
|
55
|
+
* functions/src/publicApi.ts) matches `mcp` / `frihet-mcp` / `@frihet/mcp`
|
|
56
|
+
* across three headers: `x-frihet-source`, `x-frihet-client` and `user-agent`.
|
|
57
|
+
* We send TWO markers because they survive different network paths:
|
|
58
|
+
*
|
|
59
|
+
* - `X-Frihet-Source: mcp` is the explicit, documented marker. It reaches the
|
|
60
|
+
* backend when the client talks to the Cloud Function directly (custom
|
|
61
|
+
* `baseUrl`, self-hosted proxy).
|
|
62
|
+
* - `User-Agent` is what actually reaches the backend on the DEFAULT baseUrl:
|
|
63
|
+
* `api.frihet.io` is fronted by workers/api-proxy/worker.js, whose
|
|
64
|
+
* `ALLOWED_REQUEST_HEADERS` allowlist forwards `user-agent` but drops
|
|
65
|
+
* `x-frihet-source` (verified live: /agents.json answers 200 at
|
|
66
|
+
* api.frihet.io and 401 at the Cloud Function, so the Worker is in path).
|
|
67
|
+
* Without the UA the source header is a phantom — set, then stripped at the
|
|
68
|
+
* edge, and every MCP invoice still lands as `source: 'api'`.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately carries no version: the version lives in package.json only
|
|
71
|
+
* (see src/index.ts PKG_VERSION) and a second hardcoded copy is exactly the
|
|
72
|
+
* drift `scripts/audit-mcp-refs.mjs` exists to catch.
|
|
73
|
+
*
|
|
74
|
+
* Pinned on the wire by src/__tests__/source-header-contract.test.ts.
|
|
75
|
+
*/
|
|
76
|
+
const SOURCE_MARKER = "mcp";
|
|
77
|
+
const SOURCE_USER_AGENT = "frihet-mcp-server";
|
|
27
78
|
/**
|
|
28
79
|
* Fresh idempotency key, always a syntactically valid UUID v4.
|
|
29
80
|
*
|
|
30
|
-
* `crypto.randomUUID` is
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* src/__tests__/idempotency-key-contract.test.ts asserts the shape.
|
|
81
|
+
* `crypto.randomUUID` is global on Cloudflare Workers and the supported Node
|
|
82
|
+
* runtime. The fallback remains defense in depth for constrained embedders and
|
|
83
|
+
* tests that intentionally remove the global; it must still produce a UUID,
|
|
84
|
+
* not an ad-hoc string. The backend recommends UUID v4 and the contract test
|
|
85
|
+
* asserts the shape.
|
|
36
86
|
*/
|
|
37
87
|
function newIdempotencyKey() {
|
|
38
88
|
const c = globalThis.crypto;
|
|
@@ -58,27 +108,159 @@ function normalizeIdempotencyKey(key) {
|
|
|
58
108
|
const trimmed = key?.trim();
|
|
59
109
|
return trimmed ? trimmed : undefined;
|
|
60
110
|
}
|
|
111
|
+
function normalizedContentType(contentType) {
|
|
112
|
+
return contentType.split(";", 1)[0].trim().toLowerCase();
|
|
113
|
+
}
|
|
114
|
+
function isXmlContentType(contentType) {
|
|
115
|
+
const normalized = normalizedContentType(contentType);
|
|
116
|
+
return normalized === "application/xml" || normalized === "text/xml" || normalized.endsWith("+xml");
|
|
117
|
+
}
|
|
118
|
+
function hasPdfSignature(bytes) {
|
|
119
|
+
return bytes.byteLength >= 5 &&
|
|
120
|
+
bytes[0] === 0x25 &&
|
|
121
|
+
bytes[1] === 0x50 &&
|
|
122
|
+
bytes[2] === 0x44 &&
|
|
123
|
+
bytes[3] === 0x46 &&
|
|
124
|
+
bytes[4] === 0x2d;
|
|
125
|
+
}
|
|
126
|
+
function attachmentFilename(contentDisposition) {
|
|
127
|
+
if (!contentDisposition)
|
|
128
|
+
return undefined;
|
|
129
|
+
const encoded = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
130
|
+
const quoted = contentDisposition.match(/filename="([^"]+)"/i)?.[1];
|
|
131
|
+
const plain = contentDisposition.match(/filename=([^;]+)/i)?.[1];
|
|
132
|
+
const candidate = (encoded ?? quoted ?? plain)?.trim();
|
|
133
|
+
if (!candidate)
|
|
134
|
+
return undefined;
|
|
135
|
+
try {
|
|
136
|
+
return decodeURIComponent(candidate).split(/[\\/]/).pop();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return candidate.split(/[\\/]/).pop();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function isAbortError(error) {
|
|
143
|
+
return error instanceof Error && error.name === "AbortError";
|
|
144
|
+
}
|
|
145
|
+
function decodeUtf8(bytes, label) {
|
|
146
|
+
try {
|
|
147
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new FrihetApiError(200, "invalid_response", `${label} is not valid UTF-8`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
154
|
+
/** Encode bytes without relying on Node's Buffer in browser/Worker runtimes. */
|
|
155
|
+
function encodeBase64(bytes) {
|
|
156
|
+
let encoded = "";
|
|
157
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
158
|
+
const first = bytes[index];
|
|
159
|
+
const hasSecond = index + 1 < bytes.length;
|
|
160
|
+
const hasThird = index + 2 < bytes.length;
|
|
161
|
+
const second = hasSecond ? bytes[index + 1] : 0;
|
|
162
|
+
const third = hasThird ? bytes[index + 2] : 0;
|
|
163
|
+
const chunk = (first << 16) | (second << 8) | third;
|
|
164
|
+
encoded += BASE64_ALPHABET[(chunk >>> 18) & 0x3f];
|
|
165
|
+
encoded += BASE64_ALPHABET[(chunk >>> 12) & 0x3f];
|
|
166
|
+
encoded += hasSecond ? BASE64_ALPHABET[(chunk >>> 6) & 0x3f] : "=";
|
|
167
|
+
encoded += hasThird ? BASE64_ALPHABET[chunk & 0x3f] : "=";
|
|
168
|
+
}
|
|
169
|
+
return encoded;
|
|
170
|
+
}
|
|
61
171
|
export class FrihetApiError extends Error {
|
|
62
172
|
statusCode;
|
|
63
173
|
errorCode;
|
|
64
|
-
|
|
65
|
-
|
|
174
|
+
detail;
|
|
175
|
+
constructor(statusCode, errorCode, message, detail) {
|
|
176
|
+
super(statusCode === 403
|
|
177
|
+
? sanitizeServerRemediation(message, errorCode) ?? errorCode
|
|
178
|
+
: message ?? errorCode);
|
|
66
179
|
this.statusCode = statusCode;
|
|
67
180
|
this.errorCode = errorCode;
|
|
181
|
+
this.detail = detail;
|
|
68
182
|
this.name = "FrihetApiError";
|
|
69
183
|
}
|
|
70
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Enforce the webhook secret's one-time write echo and normalize the one
|
|
187
|
+
* Firestore Timestamp that the ERP webhook route currently emits without its
|
|
188
|
+
* usual serializer. Invalid timestamp objects are preserved so the strict MCP
|
|
189
|
+
* output schema fails closed instead of fabricating a value.
|
|
190
|
+
*/
|
|
191
|
+
function normalizeWebhookRecord(webhook, allowedSecret) {
|
|
192
|
+
const normalized = { ...webhook };
|
|
193
|
+
if (typeof allowedSecret !== "string" ||
|
|
194
|
+
typeof normalized.secret !== "string" ||
|
|
195
|
+
normalized.secret !== allowedSecret) {
|
|
196
|
+
delete normalized.secret;
|
|
197
|
+
}
|
|
198
|
+
const lastTriggeredAt = normalized.lastTriggeredAt;
|
|
199
|
+
if (lastTriggeredAt !== null &&
|
|
200
|
+
typeof lastTriggeredAt === "object" &&
|
|
201
|
+
!Array.isArray(lastTriggeredAt)) {
|
|
202
|
+
const timestamp = lastTriggeredAt;
|
|
203
|
+
const seconds = timestamp._seconds;
|
|
204
|
+
const nanoseconds = timestamp._nanoseconds;
|
|
205
|
+
if (typeof seconds === "number" &&
|
|
206
|
+
Number.isInteger(seconds) &&
|
|
207
|
+
Number.isFinite(seconds) &&
|
|
208
|
+
typeof nanoseconds === "number" &&
|
|
209
|
+
Number.isInteger(nanoseconds) &&
|
|
210
|
+
nanoseconds >= 0 &&
|
|
211
|
+
nanoseconds < 1_000_000_000) {
|
|
212
|
+
const date = new Date(seconds * 1000 + Math.floor(nanoseconds / 1_000_000));
|
|
213
|
+
if (Number.isFinite(date.getTime())) {
|
|
214
|
+
normalized.lastTriggeredAt = date.toISOString();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return normalized;
|
|
219
|
+
}
|
|
220
|
+
function normalizeApiError(value, statusCode, statusText) {
|
|
221
|
+
const fallbackError = `http_${statusCode}`;
|
|
222
|
+
const record = typeof value === "object" && value !== null
|
|
223
|
+
? value
|
|
224
|
+
: undefined;
|
|
225
|
+
const error = typeof record?.error === "string" && record.error.trim()
|
|
226
|
+
? record.error
|
|
227
|
+
: fallbackError;
|
|
228
|
+
const message = typeof record?.message === "string"
|
|
229
|
+
? record.message
|
|
230
|
+
: (error !== fallbackError ? error : statusText || fallbackError);
|
|
231
|
+
return {
|
|
232
|
+
error,
|
|
233
|
+
message,
|
|
234
|
+
...(typeof record?.detail === "string" ? { detail: record.detail } : {}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
71
237
|
export class FrihetClient {
|
|
72
238
|
apiKey;
|
|
73
239
|
baseUrl;
|
|
74
240
|
timeoutMs;
|
|
241
|
+
oauthServiceSecret;
|
|
75
242
|
constructor(apiKey, baseUrl, options) {
|
|
76
243
|
if (!apiKey) {
|
|
77
244
|
throw new Error("FRIHET_API_KEY is required. Set it as an environment variable or pass it to the constructor.");
|
|
78
245
|
}
|
|
246
|
+
const resolvedBaseUrl = baseUrl ?? BASE_URL;
|
|
79
247
|
this.apiKey = apiKey;
|
|
80
|
-
this.baseUrl =
|
|
248
|
+
this.baseUrl = resolvedBaseUrl;
|
|
81
249
|
this.timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
250
|
+
if (options?.oauthServiceSecret !== undefined) {
|
|
251
|
+
if (new TextEncoder().encode(options.oauthServiceSecret).byteLength < 32
|
|
252
|
+
|| /[\r\n\0]/u.test(options.oauthServiceSecret)) {
|
|
253
|
+
throw new Error("OAuth API-key service authentication is not configured");
|
|
254
|
+
}
|
|
255
|
+
// A dashboard/API-key caller may still use a custom API base. The
|
|
256
|
+
// internal OAuth second factor may not: pin its authority at the sink so
|
|
257
|
+
// a future caller cannot exfiltrate both credentials by changing only
|
|
258
|
+
// constructor input while leaving the approved fetch fingerprint intact.
|
|
259
|
+
if (!OAUTH_SERVICE_TRUSTED_BASE_URLS.has(resolvedBaseUrl)) {
|
|
260
|
+
throw new Error("OAuth API-key service authority is not trusted");
|
|
261
|
+
}
|
|
262
|
+
this.oauthServiceSecret = options.oauthServiceSecret;
|
|
263
|
+
}
|
|
82
264
|
}
|
|
83
265
|
// ------------------------------------------------------------------ HTTP
|
|
84
266
|
// ------------------------------------------------------------------
|
|
@@ -102,7 +284,12 @@ export class FrihetClient {
|
|
|
102
284
|
"X-API-Key": this.apiKey,
|
|
103
285
|
"Content-Type": "application/json",
|
|
104
286
|
Accept: "application/json",
|
|
287
|
+
"X-Frihet-Source": SOURCE_MARKER,
|
|
288
|
+
"User-Agent": SOURCE_USER_AGENT,
|
|
105
289
|
};
|
|
290
|
+
if (this.oauthServiceSecret) {
|
|
291
|
+
headers["x-frihet-oauth-key"] = this.oauthServiceSecret;
|
|
292
|
+
}
|
|
106
293
|
if (resolvedIdempotencyKey) {
|
|
107
294
|
headers["Idempotency-Key"] = resolvedIdempotencyKey;
|
|
108
295
|
}
|
|
@@ -116,23 +303,23 @@ export class FrihetClient {
|
|
|
116
303
|
headers,
|
|
117
304
|
body: body ? JSON.stringify(body) : undefined,
|
|
118
305
|
signal: controller.signal,
|
|
306
|
+
redirect: "error",
|
|
119
307
|
});
|
|
120
308
|
}
|
|
121
309
|
catch (error) {
|
|
310
|
+
clearTimeout(timeoutId);
|
|
122
311
|
const durationMs = Math.round(Date.now() - startTime);
|
|
123
312
|
if (error instanceof Error && error.name === "AbortError") {
|
|
124
313
|
logApiCall(method, path, 408, durationMs);
|
|
125
314
|
throw new FrihetApiError(408, "request_timeout", `Request timed out after ${this.timeoutMs / 1000} seconds`);
|
|
126
315
|
}
|
|
127
316
|
logApiCall(method, path, 0, durationMs);
|
|
128
|
-
throw
|
|
129
|
-
}
|
|
130
|
-
finally {
|
|
131
|
-
clearTimeout(timeoutId);
|
|
317
|
+
throw new FrihetApiError(503, "network_error", "The network request failed before Frihet could confirm its outcome.");
|
|
132
318
|
}
|
|
133
319
|
const durationMs = Math.round(Date.now() - startTime);
|
|
134
320
|
// Rate limit handling
|
|
135
321
|
if (response.status === 429) {
|
|
322
|
+
clearTimeout(timeoutId);
|
|
136
323
|
logApiCall(method, path, 429, durationMs);
|
|
137
324
|
if (retryCount >= MAX_RETRIES) {
|
|
138
325
|
throw new FrihetApiError(429, "rate_limit_exceeded", "Rate limit exceeded after multiple retries. Please try again later.");
|
|
@@ -150,28 +337,207 @@ export class FrihetClient {
|
|
|
150
337
|
logApiCall(method, path, response.status, durationMs);
|
|
151
338
|
let errorBody;
|
|
152
339
|
try {
|
|
153
|
-
errorBody = (await response.json());
|
|
340
|
+
errorBody = normalizeApiError(await response.json(), response.status, response.statusText);
|
|
154
341
|
}
|
|
155
342
|
catch {
|
|
156
|
-
errorBody =
|
|
157
|
-
error: `http_${response.status}`,
|
|
158
|
-
message: response.statusText,
|
|
159
|
-
};
|
|
343
|
+
errorBody = normalizeApiError(undefined, response.status, response.statusText);
|
|
160
344
|
}
|
|
161
|
-
|
|
345
|
+
clearTimeout(timeoutId);
|
|
346
|
+
throw new FrihetApiError(response.status, errorBody.error, errorBody.message ?? errorBody.error, typeof errorBody.detail === "string" ? errorBody.detail : undefined);
|
|
162
347
|
}
|
|
163
348
|
logApiCall(method, path, response.status, durationMs);
|
|
164
349
|
// 204 No Content (e.g. DELETE)
|
|
165
350
|
if (response.status === 204) {
|
|
351
|
+
clearTimeout(timeoutId);
|
|
166
352
|
return undefined;
|
|
167
353
|
}
|
|
168
|
-
|
|
354
|
+
let data;
|
|
355
|
+
try {
|
|
356
|
+
// Keep the request AbortSignal live through body consumption. A server
|
|
357
|
+
// can send 2xx headers after committing a write and then stall or truncate
|
|
358
|
+
// the JSON body; clearing the timer at headers would let this hang forever
|
|
359
|
+
// and make a committed mutation look safely retryable.
|
|
360
|
+
data = await response.json();
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
clearTimeout(timeoutId);
|
|
364
|
+
const timedOut = error instanceof Error && error.name === "AbortError";
|
|
365
|
+
throw new FrihetApiError(timedOut ? 408 : response.status, timedOut ? "response_timeout" : "invalid_response_after_success", timedOut
|
|
366
|
+
? `Response body timed out after ${this.timeoutMs / 1000} seconds`
|
|
367
|
+
: "Frihet returned an unreadable success response after processing the request.");
|
|
368
|
+
}
|
|
369
|
+
clearTimeout(timeoutId);
|
|
169
370
|
// Basic response validation
|
|
170
371
|
if (data === null || data === undefined) {
|
|
171
|
-
throw new FrihetApiError(response.status,
|
|
372
|
+
throw new FrihetApiError(response.status, "invalid_response_after_success", "Frihet returned an empty success response after processing the request.");
|
|
172
373
|
}
|
|
173
374
|
return data;
|
|
174
375
|
}
|
|
376
|
+
/** Fetch a raw response. The caller owns timeout and body consumption. */
|
|
377
|
+
async fetchRaw(method, path, body, query, idempotencyKey, signal) {
|
|
378
|
+
const resolvedIdempotencyKey = normalizeIdempotencyKey(idempotencyKey) ??
|
|
379
|
+
(MUTATING_METHODS.has(method) ? newIdempotencyKey() : undefined);
|
|
380
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
381
|
+
if (query) {
|
|
382
|
+
for (const [key, value] of Object.entries(query)) {
|
|
383
|
+
if (value !== undefined)
|
|
384
|
+
url.searchParams.set(key, String(value));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const headers = {
|
|
388
|
+
"X-API-Key": this.apiKey,
|
|
389
|
+
"Content-Type": "application/json",
|
|
390
|
+
// CRITICAL: do NOT set `Accept: application/json` here. The PDF / XML
|
|
391
|
+
// endpoints must be allowed to return their native content type;
|
|
392
|
+
// forcing `Accept: application/json` would make the server negotiate
|
|
393
|
+
// an error envelope instead of the document bytes.
|
|
394
|
+
Accept: "*/*",
|
|
395
|
+
"X-Frihet-Source": SOURCE_MARKER,
|
|
396
|
+
"User-Agent": SOURCE_USER_AGENT,
|
|
397
|
+
};
|
|
398
|
+
if (this.oauthServiceSecret) {
|
|
399
|
+
headers["x-frihet-oauth-key"] = this.oauthServiceSecret;
|
|
400
|
+
}
|
|
401
|
+
if (resolvedIdempotencyKey) {
|
|
402
|
+
headers["Idempotency-Key"] = resolvedIdempotencyKey;
|
|
403
|
+
}
|
|
404
|
+
return fetch(url.toString(), {
|
|
405
|
+
method,
|
|
406
|
+
headers,
|
|
407
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
408
|
+
signal,
|
|
409
|
+
redirect: "error",
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/** Consume one response body without ever retaining more than `maxBytes`. */
|
|
413
|
+
async readBoundedBody(response, maxBytes, controller) {
|
|
414
|
+
const declaredLengthRaw = response.headers.get("content-length")?.trim();
|
|
415
|
+
const declaredLength = declaredLengthRaw && /^\d+$/.test(declaredLengthRaw)
|
|
416
|
+
? Number(declaredLengthRaw)
|
|
417
|
+
: Number.NaN;
|
|
418
|
+
if (Number.isSafeInteger(declaredLength) && declaredLength > maxBytes) {
|
|
419
|
+
controller.abort();
|
|
420
|
+
try {
|
|
421
|
+
await response.body?.cancel();
|
|
422
|
+
}
|
|
423
|
+
catch { /* best effort */ }
|
|
424
|
+
throw new FrihetApiError(413, "payload_too_large", `Document response exceeds ${maxBytes} bytes (Content-Length: ${declaredLength})`);
|
|
425
|
+
}
|
|
426
|
+
if (!response.body) {
|
|
427
|
+
throw new FrihetApiError(response.status, "invalid_response", "Response body is null");
|
|
428
|
+
}
|
|
429
|
+
const reader = response.body.getReader();
|
|
430
|
+
const chunks = [];
|
|
431
|
+
let totalBytes = 0;
|
|
432
|
+
try {
|
|
433
|
+
while (true) {
|
|
434
|
+
const { value, done } = await reader.read();
|
|
435
|
+
if (done)
|
|
436
|
+
break;
|
|
437
|
+
if (!value)
|
|
438
|
+
continue;
|
|
439
|
+
totalBytes += value.byteLength;
|
|
440
|
+
if (totalBytes > maxBytes) {
|
|
441
|
+
controller.abort();
|
|
442
|
+
try {
|
|
443
|
+
await reader.cancel("document response exceeded size limit");
|
|
444
|
+
}
|
|
445
|
+
catch { /* best effort */ }
|
|
446
|
+
throw new FrihetApiError(413, "payload_too_large", `Document response exceeds ${maxBytes} bytes during streaming`);
|
|
447
|
+
}
|
|
448
|
+
chunks.push(value);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
if (error instanceof FrihetApiError || isAbortError(error))
|
|
453
|
+
throw error;
|
|
454
|
+
try {
|
|
455
|
+
await reader.cancel("document stream failed");
|
|
456
|
+
}
|
|
457
|
+
catch { /* best effort */ }
|
|
458
|
+
controller.abort();
|
|
459
|
+
throw new FrihetApiError(response.status, "stream_failed", `Failed while reading document response: ${error instanceof Error ? error.message : String(error)}`);
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
reader.releaseLock();
|
|
463
|
+
}
|
|
464
|
+
const flat = new Uint8Array(totalBytes);
|
|
465
|
+
let offset = 0;
|
|
466
|
+
for (const chunk of chunks) {
|
|
467
|
+
flat.set(chunk, offset);
|
|
468
|
+
offset += chunk.byteLength;
|
|
469
|
+
}
|
|
470
|
+
return flat;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Bounded document fetch — ONE call, ONE Response, dispatch on Content-Type.
|
|
474
|
+
*
|
|
475
|
+
* Success and error bodies use the same bounded reader. The abort timer stays
|
|
476
|
+
* active until the body is complete, and a normal success performs one GET.
|
|
477
|
+
* A 429 may retry, matching the generic JSON client path.
|
|
478
|
+
*/
|
|
479
|
+
async requestDocument(method, path, body, query, idempotencyKey, maxBytesForContentType, retryCount = 0) {
|
|
480
|
+
const controller = new AbortController();
|
|
481
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
482
|
+
const startedAt = Date.now();
|
|
483
|
+
try {
|
|
484
|
+
const response = await this.fetchRaw(method, path, body, query, idempotencyKey, controller.signal);
|
|
485
|
+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
486
|
+
const maxBytes = response.ok ? maxBytesForContentType(contentType) : MAX_ERROR_BYTES;
|
|
487
|
+
const bytes = await this.readBoundedBody(response, maxBytes, controller);
|
|
488
|
+
const durationMs = Math.round(Date.now() - startedAt);
|
|
489
|
+
if (response.status === 429) {
|
|
490
|
+
logApiCall(method, path, 429, durationMs);
|
|
491
|
+
if (retryCount >= MAX_RETRIES) {
|
|
492
|
+
throw new FrihetApiError(429, "rate_limit_exceeded", "Rate limit exceeded after multiple retries. Please try again later.");
|
|
493
|
+
}
|
|
494
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
495
|
+
const parsedRetryAfter = retryAfter ? Number.parseInt(retryAfter, 10) : Number.NaN;
|
|
496
|
+
const delayMs = Number.isFinite(parsedRetryAfter)
|
|
497
|
+
? parsedRetryAfter * 1000
|
|
498
|
+
: DEFAULT_RETRY_DELAY_MS * Math.pow(2, retryCount);
|
|
499
|
+
logRetry(method, path, retryCount, delayMs);
|
|
500
|
+
clearTimeout(timeoutId);
|
|
501
|
+
await this.sleep(delayMs);
|
|
502
|
+
return this.requestDocument(method, path, body, query, idempotencyKey, maxBytesForContentType, retryCount + 1);
|
|
503
|
+
}
|
|
504
|
+
if (!response.ok) {
|
|
505
|
+
logApiCall(method, path, response.status, durationMs);
|
|
506
|
+
let errorBody = normalizeApiError(undefined, response.status, response.statusText);
|
|
507
|
+
try {
|
|
508
|
+
errorBody = normalizeApiError(JSON.parse(decodeUtf8(bytes, "API error response")), response.status, response.statusText);
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
if (error instanceof FrihetApiError && error.errorCode !== "invalid_response")
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
throw new FrihetApiError(response.status, errorBody.error, errorBody.message ?? errorBody.error, typeof errorBody.detail === "string" ? errorBody.detail : undefined);
|
|
515
|
+
}
|
|
516
|
+
logApiCall(method, path, response.status, durationMs);
|
|
517
|
+
const filename = attachmentFilename(response.headers.get("content-disposition"));
|
|
518
|
+
return {
|
|
519
|
+
contentType,
|
|
520
|
+
bytes,
|
|
521
|
+
sizeBytes: bytes.byteLength,
|
|
522
|
+
...(filename ? { filename } : {}),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
if (isAbortError(error)) {
|
|
527
|
+
const durationMs = Math.round(Date.now() - startedAt);
|
|
528
|
+
logApiCall(method, path, 408, durationMs);
|
|
529
|
+
throw new FrihetApiError(408, "request_timeout", `Request timed out after ${this.timeoutMs / 1000} seconds`);
|
|
530
|
+
}
|
|
531
|
+
if (error instanceof FrihetApiError)
|
|
532
|
+
throw error;
|
|
533
|
+
const durationMs = Math.round(Date.now() - startedAt);
|
|
534
|
+
logApiCall(method, path, 0, durationMs);
|
|
535
|
+
throw new FrihetApiError(503, "network_error", "The network request failed before Frihet could confirm its outcome.");
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
clearTimeout(timeoutId);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
175
541
|
sleep(ms) {
|
|
176
542
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
177
543
|
}
|
|
@@ -190,10 +556,15 @@ export class FrihetClient {
|
|
|
190
556
|
* output schema (id/clientName/items/success all read as `undefined`). This
|
|
191
557
|
* unwraps to `body.data`. Every create/update/action mutation routes through
|
|
192
558
|
* here for exactly this reason; single-object reads (`getInvoice`/…) have since
|
|
193
|
-
* #64. `deleteX` methods
|
|
194
|
-
*
|
|
195
|
-
*
|
|
559
|
+
* #64. Most `deleteX` methods keep `request` (they return `void` — the tool
|
|
560
|
+
* discards the body and synthesizes `{ success, id }`, so there is nothing to
|
|
561
|
+
* unwrap). `deleteInvoice`/`deleteQuote` are the EXCEPTION and route through
|
|
562
|
+
* here: their backend answers 204 for a real delete but 200 + `{ data, meta }`
|
|
563
|
+
* when it soft-CANCELS a non-draft document instead (see those methods).
|
|
564
|
+
* Only unwraps a non-array-object `data` (see guard below), so an
|
|
196
565
|
* array-`data` list envelope and any non-enveloped body pass through unchanged.
|
|
566
|
+
* A 204 yields `undefined` from `request`, which falls through untouched — that
|
|
567
|
+
* `undefined` is what tells the delete tools "destroyed, not cancelled".
|
|
197
568
|
*
|
|
198
569
|
* Only unwraps when the body is an object carrying a `data` property that is
|
|
199
570
|
* itself a (non-array) object — i.e. a genuine single-object envelope. It is
|
|
@@ -252,8 +623,22 @@ export class FrihetClient {
|
|
|
252
623
|
async updateInvoice(id, data) {
|
|
253
624
|
return this.requestUnwrapped("PATCH", `/invoices/${encodeURIComponent(id)}`, data);
|
|
254
625
|
}
|
|
626
|
+
/**
|
|
627
|
+
* DELETE /invoices/{id} — NOT an unconditional destroy.
|
|
628
|
+
*
|
|
629
|
+
* The backend refuses to destroy a non-draft invoice (VeriFactu hash-chain
|
|
630
|
+
* integrity) and soft-CANCELS it instead, and it distinguishes the two
|
|
631
|
+
* outcomes on the wire (`publicApi.ts` DELETE branch):
|
|
632
|
+
* - 204 No Content → the draft row was really removed
|
|
633
|
+
* - 200 + `{ data: { id, status: 'cancelled', previousStatus,
|
|
634
|
+
* cancelledVia }, meta }` → the document still exists, now cancelled
|
|
635
|
+
*
|
|
636
|
+
* This used to be typed `Promise<void>` and the body was thrown away, so the
|
|
637
|
+
* tool reported "deleted successfully" for a document that was still there.
|
|
638
|
+
* Resolves to the unwrapped soft-cancel payload on 200, `undefined` on 204.
|
|
639
|
+
*/
|
|
255
640
|
async deleteInvoice(id) {
|
|
256
|
-
return this.
|
|
641
|
+
return this.requestUnwrapped("DELETE", `/invoices/${encodeURIComponent(id)}`);
|
|
257
642
|
}
|
|
258
643
|
async searchInvoices(query, params) {
|
|
259
644
|
return this.requestPaginated("GET", "/invoices", undefined, {
|
|
@@ -326,7 +711,9 @@ export class FrihetClient {
|
|
|
326
711
|
cursor: params?.after,
|
|
327
712
|
fields: params?.fields,
|
|
328
713
|
q: params?.q,
|
|
329
|
-
|
|
714
|
+
// Public API accepts the literal strings "true"/"false". Numeric 1/0
|
|
715
|
+
// makes `isActive=true` evaluate as false in the backend parser.
|
|
716
|
+
isActive: params?.isActive !== undefined ? String(params.isActive) : undefined,
|
|
330
717
|
});
|
|
331
718
|
}
|
|
332
719
|
async getProduct(id) {
|
|
@@ -365,8 +752,14 @@ export class FrihetClient {
|
|
|
365
752
|
async updateQuote(id, data) {
|
|
366
753
|
return this.requestUnwrapped("PATCH", `/quotes/${encodeURIComponent(id)}`, data);
|
|
367
754
|
}
|
|
755
|
+
/**
|
|
756
|
+
* DELETE /quotes/{id} — same two-outcome contract as {@link deleteInvoice}:
|
|
757
|
+
* the backend soft-CANCELS a non-draft quote (200 + body), destroys only a
|
|
758
|
+
* clean draft with no delivery/response/attachment/conversion evidence (204),
|
|
759
|
+
* and refuses a protected draft with 409.
|
|
760
|
+
*/
|
|
368
761
|
async deleteQuote(id) {
|
|
369
|
-
return this.
|
|
762
|
+
return this.requestUnwrapped("DELETE", `/quotes/${encodeURIComponent(id)}`);
|
|
370
763
|
}
|
|
371
764
|
// ---------------------------------------------------------------- Vendors
|
|
372
765
|
// ----------------------------------------------------------------
|
|
@@ -394,21 +787,72 @@ export class FrihetClient {
|
|
|
394
787
|
// ---------------------------------------------------------------- Invoice Actions
|
|
395
788
|
// ----------------------------------------------------------------
|
|
396
789
|
async sendInvoice(id, to) {
|
|
397
|
-
return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/send`, to ? { to } : undefined);
|
|
790
|
+
return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/send`, to ? { recipientEmail: to } : undefined);
|
|
398
791
|
}
|
|
399
792
|
async markInvoicePaid(id, paidDate) {
|
|
400
793
|
return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/paid`, paidDate ? { paidDate } : undefined);
|
|
401
794
|
}
|
|
402
795
|
async getInvoicePdf(id) {
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
796
|
+
// One fetch, one Response: validate MIME and signature only after the
|
|
797
|
+
// bounded stream completes. The ERP endpoint has always returned bytes;
|
|
798
|
+
// there is no pre-signed-URL JSON success contract to emulate.
|
|
799
|
+
const url = `/invoices/${encodeURIComponent(id)}/pdf`;
|
|
800
|
+
const doc = await this.requestDocument("GET", url, undefined, undefined, undefined, (contentType) => normalizedContentType(contentType) === "application/pdf"
|
|
801
|
+
? MAX_PDF_BYTES
|
|
802
|
+
: MAX_ERROR_BYTES);
|
|
803
|
+
if (normalizedContentType(doc.contentType) !== "application/pdf") {
|
|
804
|
+
throw new FrihetApiError(200, "invalid_response", `Invoice PDF endpoint returned unexpected Content-Type: ${doc.contentType}`);
|
|
805
|
+
}
|
|
806
|
+
if (!hasPdfSignature(doc.bytes)) {
|
|
807
|
+
throw new FrihetApiError(200, "invalid_response", "Invoice PDF response is malformed");
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
id,
|
|
811
|
+
contentType: doc.contentType,
|
|
812
|
+
sizeBytes: doc.sizeBytes,
|
|
813
|
+
base64: encodeBase64(doc.bytes),
|
|
814
|
+
...(doc.filename ? { filename: doc.filename } : {}),
|
|
815
|
+
};
|
|
406
816
|
}
|
|
407
817
|
async getInvoiceEInvoice(invoiceId) {
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
818
|
+
// Stored e-invoices are either XML or Factur-X PDF. Select the size cap
|
|
819
|
+
// from the actual MIME before reading and preserve the request identity.
|
|
820
|
+
const url = `/invoices/${encodeURIComponent(invoiceId)}/xml`;
|
|
821
|
+
const doc = await this.requestDocument("GET", url, undefined, undefined, undefined, (contentType) => {
|
|
822
|
+
const normalized = normalizedContentType(contentType);
|
|
823
|
+
if (normalized === "application/pdf")
|
|
824
|
+
return MAX_PDF_BYTES;
|
|
825
|
+
if (isXmlContentType(contentType))
|
|
826
|
+
return MAX_XML_BYTES;
|
|
827
|
+
return MAX_ERROR_BYTES;
|
|
828
|
+
});
|
|
829
|
+
const normalized = normalizedContentType(doc.contentType);
|
|
830
|
+
if (normalized === "application/pdf") {
|
|
831
|
+
if (!hasPdfSignature(doc.bytes)) {
|
|
832
|
+
throw new FrihetApiError(200, "invalid_response", "Factur-X PDF response is malformed");
|
|
833
|
+
}
|
|
834
|
+
return {
|
|
835
|
+
id: invoiceId,
|
|
836
|
+
contentType: doc.contentType,
|
|
837
|
+
sizeBytes: doc.sizeBytes,
|
|
838
|
+
base64: encodeBase64(doc.bytes),
|
|
839
|
+
...(doc.filename ? { filename: doc.filename } : {}),
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
if (!isXmlContentType(doc.contentType)) {
|
|
843
|
+
throw new FrihetApiError(200, "invalid_response", `E-invoice endpoint returned unexpected Content-Type: ${doc.contentType}`);
|
|
844
|
+
}
|
|
845
|
+
const xmlText = decodeUtf8(doc.bytes, "E-invoice XML response");
|
|
846
|
+
if (!xmlText.trimStart().startsWith("<")) {
|
|
847
|
+
throw new FrihetApiError(200, "invalid_response", "E-invoice XML response is malformed");
|
|
848
|
+
}
|
|
849
|
+
return {
|
|
850
|
+
id: invoiceId,
|
|
851
|
+
contentType: doc.contentType,
|
|
852
|
+
xml: xmlText,
|
|
853
|
+
sizeBytes: doc.sizeBytes,
|
|
854
|
+
...(doc.filename ? { filename: doc.filename } : {}),
|
|
855
|
+
};
|
|
412
856
|
}
|
|
413
857
|
/**
|
|
414
858
|
* `POST /v1/invoices/:id/credit-note` — creates a rectificativa DRAFT.
|
|
@@ -431,24 +875,38 @@ export class FrihetClient {
|
|
|
431
875
|
// ---------------------------------------------------------------- Quote Actions
|
|
432
876
|
// ----------------------------------------------------------------
|
|
433
877
|
async sendQuote(id, to) {
|
|
434
|
-
return this.requestUnwrapped("POST", `/quotes/${encodeURIComponent(id)}/send`, to ? { to } : undefined);
|
|
878
|
+
return this.requestUnwrapped("POST", `/quotes/${encodeURIComponent(id)}/send`, to ? { recipientEmail: to } : undefined);
|
|
435
879
|
}
|
|
436
880
|
// ---------------------------------------------------------------- Webhooks
|
|
437
881
|
// ----------------------------------------------------------------
|
|
438
|
-
async listWebhooks(
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
882
|
+
async listWebhooks() {
|
|
883
|
+
const result = await this.request("GET", "/webhooks");
|
|
884
|
+
if (result === null ||
|
|
885
|
+
typeof result !== "object" ||
|
|
886
|
+
!("data" in result) ||
|
|
887
|
+
!("total" in result) ||
|
|
888
|
+
!Array.isArray(result.data) ||
|
|
889
|
+
typeof result.total !== "number") {
|
|
890
|
+
throw new FrihetApiError(200, "invalid_response", "API returned invalid webhook list response");
|
|
891
|
+
}
|
|
892
|
+
// `/webhooks` is deliberately unpaginated. Keep its `{ data, total }`
|
|
893
|
+
// contract and discard only the transport-level `meta` sibling.
|
|
894
|
+
return {
|
|
895
|
+
data: result.data.map((webhook) => normalizeWebhookRecord(webhook)),
|
|
896
|
+
total: result.total,
|
|
897
|
+
};
|
|
443
898
|
}
|
|
444
899
|
async getWebhook(id) {
|
|
445
|
-
|
|
900
|
+
const result = await this.requestUnwrapped("GET", `/webhooks/${encodeURIComponent(id)}`);
|
|
901
|
+
return normalizeWebhookRecord(result);
|
|
446
902
|
}
|
|
447
903
|
async createWebhook(data) {
|
|
448
|
-
|
|
904
|
+
const result = await this.requestUnwrapped("POST", "/webhooks", data);
|
|
905
|
+
return normalizeWebhookRecord(result, typeof data.secret === "string" ? data.secret : undefined);
|
|
449
906
|
}
|
|
450
907
|
async updateWebhook(id, data) {
|
|
451
|
-
|
|
908
|
+
const result = await this.requestUnwrapped("PATCH", `/webhooks/${encodeURIComponent(id)}`, data);
|
|
909
|
+
return normalizeWebhookRecord(result);
|
|
452
910
|
}
|
|
453
911
|
async deleteWebhook(id) {
|
|
454
912
|
return this.request("DELETE", `/webhooks/${encodeURIComponent(id)}`);
|
|
@@ -663,10 +1121,10 @@ export class FrihetClient {
|
|
|
663
1121
|
// ---------------------------------------------------------------- Intelligence
|
|
664
1122
|
// ----------------------------------------------------------------
|
|
665
1123
|
async getBusinessContext() {
|
|
666
|
-
return this.
|
|
1124
|
+
return this.requestUnwrapped("GET", "/context");
|
|
667
1125
|
}
|
|
668
1126
|
async getMonthlySummary(month) {
|
|
669
|
-
return this.
|
|
1127
|
+
return this.requestUnwrapped("GET", "/monthly", undefined, {
|
|
670
1128
|
month,
|
|
671
1129
|
});
|
|
672
1130
|
}
|
|
@@ -908,7 +1366,8 @@ export class FrihetClient {
|
|
|
908
1366
|
return this.requestUnwrapped("POST", "/banking/rules", data);
|
|
909
1367
|
}
|
|
910
1368
|
// ---------------------------------------------------------------- HR (Leaves + Attendance + Anomalies)
|
|
911
|
-
//
|
|
1369
|
+
// The ERP routes are live. Single-object family reports use the standard
|
|
1370
|
+
// `{ data, meta }` response envelope and must be unwrapped exactly once.
|
|
912
1371
|
async listLeaves(params) {
|
|
913
1372
|
return this.requestPaginated("GET", "/leaves", undefined, {
|
|
914
1373
|
employeeId: params?.employeeId,
|
|
@@ -939,7 +1398,7 @@ export class FrihetClient {
|
|
|
939
1398
|
return this.requestUnwrapped("PATCH", `/time-entries/${encodeURIComponent(entryId)}/clock-out`, {});
|
|
940
1399
|
}
|
|
941
1400
|
async getOvertimeReport(params) {
|
|
942
|
-
return this.
|
|
1401
|
+
return this.requestUnwrapped("GET", "/time-entries/overtime", undefined, {
|
|
943
1402
|
period: params.period,
|
|
944
1403
|
employeeId: params.employeeId,
|
|
945
1404
|
});
|
|
@@ -960,15 +1419,16 @@ export class FrihetClient {
|
|
|
960
1419
|
return this.requestUnwrapped("POST", `/webhooks/${encodeURIComponent(id)}/test`, data ?? {});
|
|
961
1420
|
}
|
|
962
1421
|
// ---------------------------------------------------------------- Payroll
|
|
963
|
-
//
|
|
1422
|
+
// These live read routes return normalized payroll-preparation records in
|
|
1423
|
+
// the standard single-object `{ data, meta }` envelope.
|
|
964
1424
|
async exportPayroll(params) {
|
|
965
|
-
return this.
|
|
1425
|
+
return this.requestUnwrapped("GET", "/payroll/prep/export", undefined, {
|
|
966
1426
|
format: params.format,
|
|
967
1427
|
month: params.month,
|
|
968
1428
|
});
|
|
969
1429
|
}
|
|
970
1430
|
async getPayrollChecklist(params) {
|
|
971
|
-
return this.
|
|
1431
|
+
return this.requestUnwrapped("GET", "/payroll/prep/employees", undefined, {
|
|
972
1432
|
month: params.month,
|
|
973
1433
|
});
|
|
974
1434
|
}
|
|
@@ -981,20 +1441,22 @@ export class FrihetClient {
|
|
|
981
1441
|
return this.requestUnwrapped("PATCH", "/onboarding/persona", data);
|
|
982
1442
|
}
|
|
983
1443
|
// ---------------------------------------------------------------- Permissions
|
|
984
|
-
//
|
|
1444
|
+
// These are standard ERP family responses: unwrap their single-object
|
|
1445
|
+
// `{ data, meta }` envelope before handing the payload to MCP output schemas.
|
|
985
1446
|
async getPermissionsMatrix() {
|
|
986
|
-
return this.
|
|
1447
|
+
return this.requestUnwrapped("GET", "/permissions/matrix");
|
|
987
1448
|
}
|
|
988
1449
|
async getMyPermissions() {
|
|
989
|
-
return this.
|
|
1450
|
+
return this.requestUnwrapped("GET", "/permissions/me");
|
|
990
1451
|
}
|
|
991
1452
|
// ---------------------------------------------------------------- Period Close
|
|
992
|
-
//
|
|
1453
|
+
// Period reads are live and enveloped. Fiscal close/reopen writes remain
|
|
1454
|
+
// honest ERP 501 responses and are intentionally unchanged here.
|
|
993
1455
|
async getCurrentPeriod(params) {
|
|
994
|
-
if (params?.
|
|
995
|
-
return this.
|
|
1456
|
+
if (params?.fiscalYear) {
|
|
1457
|
+
return this.requestUnwrapped("GET", `/periods/${encodeURIComponent(params.fiscalYear)}`);
|
|
996
1458
|
}
|
|
997
|
-
return this.
|
|
1459
|
+
return this.requestUnwrapped("GET", "/periods/current");
|
|
998
1460
|
}
|
|
999
1461
|
async closePeriod(data) {
|
|
1000
1462
|
return this.requestUnwrapped("POST", "/periods/close", data);
|