@open-mercato/tillio 0.7.1-develop.7176.1.d229567a50
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/.turbo/turbo-build.log +2 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js +17 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js +27 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js +47 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js +11 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js +13 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js.map +7 -0
- package/dist/modules/tillio/acl.js +9 -0
- package/dist/modules/tillio/acl.js.map +7 -0
- package/dist/modules/tillio/api/operators/[id]/route.js +78 -0
- package/dist/modules/tillio/api/operators/[id]/route.js.map +7 -0
- package/dist/modules/tillio/api/operators/route.js +171 -0
- package/dist/modules/tillio/api/operators/route.js.map +7 -0
- package/dist/modules/tillio/api/pull/route.js +180 -0
- package/dist/modules/tillio/api/pull/route.js.map +7 -0
- package/dist/modules/tillio/cli.js +108 -0
- package/dist/modules/tillio/cli.js.map +7 -0
- package/dist/modules/tillio/di.js +16 -0
- package/dist/modules/tillio/di.js.map +7 -0
- package/dist/modules/tillio/index.js +12 -0
- package/dist/modules/tillio/index.js.map +7 -0
- package/dist/modules/tillio/integration.js +62 -0
- package/dist/modules/tillio/integration.js.map +7 -0
- package/dist/modules/tillio/lib/adapter.js +107 -0
- package/dist/modules/tillio/lib/adapter.js.map +7 -0
- package/dist/modules/tillio/lib/client.js +249 -0
- package/dist/modules/tillio/lib/client.js.map +7 -0
- package/dist/modules/tillio/lib/environment.js +34 -0
- package/dist/modules/tillio/lib/environment.js.map +7 -0
- package/dist/modules/tillio/lib/error-codes.js +59 -0
- package/dist/modules/tillio/lib/error-codes.js.map +7 -0
- package/dist/modules/tillio/lib/errors.js +12 -0
- package/dist/modules/tillio/lib/errors.js.map +7 -0
- package/dist/modules/tillio/lib/health.js +41 -0
- package/dist/modules/tillio/lib/health.js.map +7 -0
- package/dist/modules/tillio/lib/locking.js +18 -0
- package/dist/modules/tillio/lib/locking.js.map +7 -0
- package/dist/modules/tillio/lib/normalizer.js +134 -0
- package/dist/modules/tillio/lib/normalizer.js.map +7 -0
- package/dist/modules/tillio/lib/operators-store.js +62 -0
- package/dist/modules/tillio/lib/operators-store.js.map +7 -0
- package/dist/modules/tillio/lib/operators.js +168 -0
- package/dist/modules/tillio/lib/operators.js.map +7 -0
- package/dist/modules/tillio/lib/preset.js +122 -0
- package/dist/modules/tillio/lib/preset.js.map +7 -0
- package/dist/modules/tillio/lib/pull-job.js +265 -0
- package/dist/modules/tillio/lib/pull-job.js.map +7 -0
- package/dist/modules/tillio/lib/pull-readiness.js +36 -0
- package/dist/modules/tillio/lib/pull-readiness.js.map +7 -0
- package/dist/modules/tillio/lib/queue.js +22 -0
- package/dist/modules/tillio/lib/queue.js.map +7 -0
- package/dist/modules/tillio/lib/tz.js +85 -0
- package/dist/modules/tillio/lib/tz.js.map +7 -0
- package/dist/modules/tillio/lib/url-guard.js +24 -0
- package/dist/modules/tillio/lib/url-guard.js.map +7 -0
- package/dist/modules/tillio/setup.js +45 -0
- package/dist/modules/tillio/setup.js.map +7 -0
- package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js +208 -0
- package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js.map +7 -0
- package/dist/modules/tillio/widgets/injection/operators-config/widget.js +17 -0
- package/dist/modules/tillio/widgets/injection/operators-config/widget.js.map +7 -0
- package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js +148 -0
- package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js.map +7 -0
- package/dist/modules/tillio/widgets/injection/pull-calls/widget.js +17 -0
- package/dist/modules/tillio/widgets/injection/pull-calls/widget.js.map +7 -0
- package/dist/modules/tillio/widgets/injection-table.js +21 -0
- package/dist/modules/tillio/widgets/injection-table.js.map +7 -0
- package/dist/modules/tillio/workers/tillio-pull.js +17 -0
- package/dist/modules/tillio/workers/tillio-pull.js.map +7 -0
- package/jest.config.cjs +35 -0
- package/package.json +92 -0
- package/src/index.ts +1 -0
- package/src/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.ts +38 -0
- package/src/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.ts +44 -0
- package/src/modules/tillio/__integration__/TC-PHONE-003-operators.spec.ts +76 -0
- package/src/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.ts +22 -0
- package/src/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.ts +27 -0
- package/src/modules/tillio/__tests__/adapter-fetch-calls.test.ts +148 -0
- package/src/modules/tillio/__tests__/client.test.ts +203 -0
- package/src/modules/tillio/__tests__/health.test.ts +122 -0
- package/src/modules/tillio/__tests__/normalizer.test.ts +143 -0
- package/src/modules/tillio/__tests__/operators-routes.test.ts +100 -0
- package/src/modules/tillio/__tests__/operators-store.test.ts +104 -0
- package/src/modules/tillio/__tests__/operators.test.ts +250 -0
- package/src/modules/tillio/__tests__/preset.test.ts +337 -0
- package/src/modules/tillio/__tests__/pull-body.test.ts +43 -0
- package/src/modules/tillio/__tests__/pull-job.test.ts +250 -0
- package/src/modules/tillio/__tests__/pull-readiness.test.ts +143 -0
- package/src/modules/tillio/__tests__/pull-route.test.ts +148 -0
- package/src/modules/tillio/__tests__/tz.test.ts +53 -0
- package/src/modules/tillio/__tests__/url-guard.test.ts +46 -0
- package/src/modules/tillio/acl.ts +5 -0
- package/src/modules/tillio/api/operators/[id]/route.ts +90 -0
- package/src/modules/tillio/api/operators/route.ts +191 -0
- package/src/modules/tillio/api/pull/route.ts +213 -0
- package/src/modules/tillio/cli.ts +125 -0
- package/src/modules/tillio/di.ts +16 -0
- package/src/modules/tillio/i18n/de.json +73 -0
- package/src/modules/tillio/i18n/en.json +73 -0
- package/src/modules/tillio/i18n/es.json +73 -0
- package/src/modules/tillio/i18n/ko.json +73 -0
- package/src/modules/tillio/i18n/pl.json +73 -0
- package/src/modules/tillio/index.ts +8 -0
- package/src/modules/tillio/integration.ts +58 -0
- package/src/modules/tillio/lib/adapter.ts +135 -0
- package/src/modules/tillio/lib/client.ts +329 -0
- package/src/modules/tillio/lib/environment.ts +53 -0
- package/src/modules/tillio/lib/error-codes.ts +67 -0
- package/src/modules/tillio/lib/errors.ts +11 -0
- package/src/modules/tillio/lib/health.ts +57 -0
- package/src/modules/tillio/lib/locking.ts +30 -0
- package/src/modules/tillio/lib/normalizer.ts +184 -0
- package/src/modules/tillio/lib/operators-store.ts +77 -0
- package/src/modules/tillio/lib/operators.ts +269 -0
- package/src/modules/tillio/lib/preset.ts +226 -0
- package/src/modules/tillio/lib/pull-job.ts +390 -0
- package/src/modules/tillio/lib/pull-readiness.ts +77 -0
- package/src/modules/tillio/lib/queue.ts +33 -0
- package/src/modules/tillio/lib/tz.ts +121 -0
- package/src/modules/tillio/lib/url-guard.ts +34 -0
- package/src/modules/tillio/setup.ts +49 -0
- package/src/modules/tillio/widgets/injection/operators-config/widget.client.tsx +304 -0
- package/src/modules/tillio/widgets/injection/operators-config/widget.ts +16 -0
- package/src/modules/tillio/widgets/injection/pull-calls/widget.client.tsx +214 -0
- package/src/modules/tillio/widgets/injection/pull-calls/widget.ts +16 -0
- package/src/modules/tillio/widgets/injection-table.ts +19 -0
- package/src/modules/tillio/workers/tillio-pull.ts +18 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { FetchTimeoutError } from "@open-mercato/shared/lib/http/fetchWithTimeout";
|
|
3
|
+
import { safeOutboundFetch, UnsafeOutboundUrlError } from "@open-mercato/shared/lib/url-safety";
|
|
4
|
+
import { TillioApiError } from "./errors.js";
|
|
5
|
+
import { assertPublicTillioApiUrl, TILLIO_URL_SUBJECT } from "./url-guard.js";
|
|
6
|
+
const TILLIO_REQUEST_TIMEOUT_MS = 15e3;
|
|
7
|
+
const TILLIO_MIN_REQUEST_SPACING_MS = 200;
|
|
8
|
+
const TILLIO_MAX_RETRIES = 3;
|
|
9
|
+
const TILLIO_RETRY_BASE_MS = 500;
|
|
10
|
+
const TILLIO_RETRY_MAX_MS = 8e3;
|
|
11
|
+
const TILLIO_MAX_REDIRECTS = 3;
|
|
12
|
+
const RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 503]);
|
|
13
|
+
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
14
|
+
let lastRequestAt = 0;
|
|
15
|
+
function redactTillioUrl(rawUrl) {
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(rawUrl);
|
|
18
|
+
if (url.searchParams.has("token")) url.searchParams.set("token", "***");
|
|
19
|
+
return url.toString();
|
|
20
|
+
} catch {
|
|
21
|
+
return rawUrl.split("?")[0] ?? "";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const addConfigResponseSchema = z.object({ token: z.string().min(1) });
|
|
25
|
+
const errorResponseSchema = z.object({
|
|
26
|
+
error: z.string(),
|
|
27
|
+
message: z.string().optional()
|
|
28
|
+
});
|
|
29
|
+
const counterSchema = z.union([z.string(), z.number()]).transform((value, ctx) => {
|
|
30
|
+
const parsed = Number(value);
|
|
31
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
32
|
+
ctx.addIssue({ code: "custom", message: "Tillio returned an unusable pagination counter" });
|
|
33
|
+
return z.NEVER;
|
|
34
|
+
}
|
|
35
|
+
return Math.trunc(parsed);
|
|
36
|
+
});
|
|
37
|
+
const getCallsResponseSchema = z.object({
|
|
38
|
+
calls: z.array(z.record(z.string(), z.unknown())).default([]),
|
|
39
|
+
pagination: z.object({
|
|
40
|
+
total: counterSchema,
|
|
41
|
+
page: counterSchema,
|
|
42
|
+
pages: counterSchema
|
|
43
|
+
})
|
|
44
|
+
});
|
|
45
|
+
function sleep(ms) {
|
|
46
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
+
}
|
|
48
|
+
function clampRetryAfterMs(header, fallback) {
|
|
49
|
+
if (!header) return fallback;
|
|
50
|
+
const seconds = Number(header);
|
|
51
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1e3, TILLIO_RETRY_MAX_MS);
|
|
52
|
+
return fallback;
|
|
53
|
+
}
|
|
54
|
+
function backoffMs(attempt) {
|
|
55
|
+
const exponential = Math.min(TILLIO_RETRY_BASE_MS * 2 ** attempt, TILLIO_RETRY_MAX_MS);
|
|
56
|
+
const jitter = Math.floor(Math.random() * TILLIO_RETRY_BASE_MS);
|
|
57
|
+
return exponential + jitter;
|
|
58
|
+
}
|
|
59
|
+
function createTillioClient(environment, deps = {}) {
|
|
60
|
+
const apiUrl = environment.apiUrl.trim().replace(/\/+$/, "");
|
|
61
|
+
const apiKey = environment.apiKey.trim();
|
|
62
|
+
const tenantSystemId = environment.tenantSystemId.trim();
|
|
63
|
+
if (!apiUrl) throw new Error("Tillio environment is missing the API URL.");
|
|
64
|
+
if (!apiKey) throw new Error("Tillio environment is missing the API key.");
|
|
65
|
+
if (!tenantSystemId) throw new Error("Tillio environment is missing the tenant system id.");
|
|
66
|
+
assertPublicTillioApiUrl(apiUrl);
|
|
67
|
+
const apiHostname = new URL(apiUrl).hostname.toLowerCase();
|
|
68
|
+
function buildHeaders(tenantDomain, token) {
|
|
69
|
+
const headers = {
|
|
70
|
+
"X-Api-Key": apiKey,
|
|
71
|
+
"X-System": tenantSystemId,
|
|
72
|
+
"X-Tenant": tenantSystemId,
|
|
73
|
+
"X-Tenant-Domain": tenantDomain
|
|
74
|
+
};
|
|
75
|
+
if (token) headers["X-Token"] = token;
|
|
76
|
+
return headers;
|
|
77
|
+
}
|
|
78
|
+
async function throttle() {
|
|
79
|
+
const wait = Math.max(0, TILLIO_MIN_REQUEST_SPACING_MS - (Date.now() - lastRequestAt));
|
|
80
|
+
if (wait > 0) await sleep(wait);
|
|
81
|
+
lastRequestAt = Date.now();
|
|
82
|
+
}
|
|
83
|
+
function resolveUrl(path, query) {
|
|
84
|
+
const url = new URL(path.replace(/^\/+/, ""), `${apiUrl}/`);
|
|
85
|
+
if (query) for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value);
|
|
86
|
+
return url.toString();
|
|
87
|
+
}
|
|
88
|
+
async function fetchOnce(method, url, init) {
|
|
89
|
+
const hasBody = init.body !== void 0;
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const timer = setTimeout(
|
|
92
|
+
() => controller.abort(new FetchTimeoutError(redactTillioUrl(url), TILLIO_REQUEST_TIMEOUT_MS)),
|
|
93
|
+
TILLIO_REQUEST_TIMEOUT_MS
|
|
94
|
+
);
|
|
95
|
+
try {
|
|
96
|
+
return await safeOutboundFetch(
|
|
97
|
+
url,
|
|
98
|
+
{
|
|
99
|
+
method,
|
|
100
|
+
headers: {
|
|
101
|
+
accept: "application/json",
|
|
102
|
+
...hasBody ? { "content-type": "application/json" } : {},
|
|
103
|
+
...init.headers
|
|
104
|
+
},
|
|
105
|
+
...hasBody ? { body: JSON.stringify(init.body) } : {},
|
|
106
|
+
signal: controller.signal
|
|
107
|
+
},
|
|
108
|
+
{ subject: TILLIO_URL_SUBJECT, lookupHost: deps.lookupHost, fetchImpl: deps.fetchImpl }
|
|
109
|
+
);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err?.name === "AbortError") {
|
|
112
|
+
const reason = controller.signal.reason;
|
|
113
|
+
if (reason instanceof Error) throw reason;
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function resolveRedirectTarget(currentUrl, response) {
|
|
121
|
+
const location = response.headers.get("location");
|
|
122
|
+
if (!location) {
|
|
123
|
+
throw new TillioApiError(
|
|
124
|
+
`Tillio answered ${response.status} without a Location header`,
|
|
125
|
+
response.status,
|
|
126
|
+
"redirect"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
let target;
|
|
130
|
+
try {
|
|
131
|
+
target = new URL(location, currentUrl);
|
|
132
|
+
} catch {
|
|
133
|
+
throw new TillioApiError(`Tillio redirected to an unusable location`, response.status, "redirect");
|
|
134
|
+
}
|
|
135
|
+
const current = new URL(currentUrl);
|
|
136
|
+
if (target.hostname.toLowerCase() !== apiHostname) {
|
|
137
|
+
throw new TillioApiError(
|
|
138
|
+
`Tillio redirected off the configured host, to "${target.hostname}"`,
|
|
139
|
+
response.status,
|
|
140
|
+
"redirect"
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (current.protocol === "https:" && target.protocol !== "https:") {
|
|
144
|
+
throw new TillioApiError("Tillio redirected from https to a plaintext URL", response.status, "redirect");
|
|
145
|
+
}
|
|
146
|
+
return target.toString();
|
|
147
|
+
}
|
|
148
|
+
async function rawRequest(method, url, init, attempt = 0) {
|
|
149
|
+
await throttle();
|
|
150
|
+
let currentMethod = method;
|
|
151
|
+
let currentUrl = url;
|
|
152
|
+
let currentInit = init;
|
|
153
|
+
let response;
|
|
154
|
+
for (let hop = 0; ; hop += 1) {
|
|
155
|
+
try {
|
|
156
|
+
response = await fetchOnce(currentMethod, currentUrl, currentInit);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (err instanceof FetchTimeoutError) {
|
|
159
|
+
throw new TillioApiError(
|
|
160
|
+
`Tillio request timed out: ${currentMethod} ${redactTillioUrl(currentUrl)}`,
|
|
161
|
+
0,
|
|
162
|
+
"timeout"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (err instanceof UnsafeOutboundUrlError) {
|
|
166
|
+
throw new TillioApiError(`Tillio request blocked: ${err.message}`, 0, err.reason);
|
|
167
|
+
}
|
|
168
|
+
const message = err instanceof Error ? err.message : "network error";
|
|
169
|
+
throw new TillioApiError(
|
|
170
|
+
`Tillio request failed: ${currentMethod} ${redactTillioUrl(currentUrl)}: ${message}`,
|
|
171
|
+
0,
|
|
172
|
+
"network"
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (!REDIRECT_STATUSES.has(response.status)) break;
|
|
176
|
+
if (hop >= TILLIO_MAX_REDIRECTS) {
|
|
177
|
+
throw new TillioApiError(
|
|
178
|
+
`Tillio redirected more than ${TILLIO_MAX_REDIRECTS} times`,
|
|
179
|
+
response.status,
|
|
180
|
+
"redirect"
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
currentUrl = resolveRedirectTarget(currentUrl, response);
|
|
184
|
+
if (response.status === 303 && currentMethod !== "GET" && currentMethod !== "HEAD") {
|
|
185
|
+
currentMethod = "GET";
|
|
186
|
+
currentInit = { headers: currentInit.headers };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (RETRYABLE_STATUSES.has(response.status) && attempt < TILLIO_MAX_RETRIES) {
|
|
190
|
+
const delay = clampRetryAfterMs(response.headers.get("retry-after"), backoffMs(attempt));
|
|
191
|
+
await sleep(delay);
|
|
192
|
+
return rawRequest(method, url, init, attempt + 1);
|
|
193
|
+
}
|
|
194
|
+
return response;
|
|
195
|
+
}
|
|
196
|
+
async function requestJson(method, path, options) {
|
|
197
|
+
const url = resolveUrl(path, options.query);
|
|
198
|
+
const response = await rawRequest(method, url, { headers: options.headers, body: options.body });
|
|
199
|
+
const text = await response.text();
|
|
200
|
+
let parsed;
|
|
201
|
+
try {
|
|
202
|
+
parsed = text ? JSON.parse(text) : {};
|
|
203
|
+
} catch {
|
|
204
|
+
throw new TillioApiError(
|
|
205
|
+
response.ok ? "Tillio returned a non-JSON response" : `Tillio request failed (${response.status})`,
|
|
206
|
+
response.status,
|
|
207
|
+
text.slice(0, 200)
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const asError = errorResponseSchema.safeParse(parsed);
|
|
211
|
+
if (asError.success && asError.data.error) {
|
|
212
|
+
throw new TillioApiError(asError.data.message ?? asError.data.error, response.status, asError.data.error);
|
|
213
|
+
}
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
throw new TillioApiError(`Tillio request failed (${response.status})`, response.status, JSON.stringify(parsed).slice(0, 200));
|
|
216
|
+
}
|
|
217
|
+
return options.schema.parse(parsed);
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
async getPlugins(tenantDomain) {
|
|
221
|
+
return requestJson("GET", "/api/plugins", { headers: buildHeaders(tenantDomain), schema: z.unknown() });
|
|
222
|
+
},
|
|
223
|
+
async getCalls(params) {
|
|
224
|
+
const query = {};
|
|
225
|
+
if (params.from) query.from = params.from;
|
|
226
|
+
if (params.to) query.to = params.to;
|
|
227
|
+
if (params.page !== void 0) query.page = String(params.page);
|
|
228
|
+
return requestJson("GET", "/api/call", {
|
|
229
|
+
headers: buildHeaders(params.tenantDomain, params.token),
|
|
230
|
+
query,
|
|
231
|
+
schema: getCallsResponseSchema
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
async validateConfig(plugin, config, tenantDomain) {
|
|
235
|
+
await requestJson("POST", "/api/config/validate", { headers: buildHeaders(tenantDomain), body: { plugin, config }, schema: z.unknown() });
|
|
236
|
+
},
|
|
237
|
+
async addConfig(plugin, config, tenantDomain) {
|
|
238
|
+
return requestJson("POST", "/api/config", { headers: buildHeaders(tenantDomain), body: { plugin, config }, schema: addConfigResponseSchema });
|
|
239
|
+
},
|
|
240
|
+
async deleteConfig(plugin, token, tenantDomain) {
|
|
241
|
+
await requestJson("DELETE", "/api/config", { headers: buildHeaders(tenantDomain), query: { plugin, token }, schema: z.unknown() });
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
export {
|
|
246
|
+
createTillioClient,
|
|
247
|
+
redactTillioUrl
|
|
248
|
+
};
|
|
249
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/tillio/lib/client.ts"],
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport { FetchTimeoutError } from '@open-mercato/shared/lib/http/fetchWithTimeout'\nimport { safeOutboundFetch, UnsafeOutboundUrlError, type HostLookup } from '@open-mercato/shared/lib/url-safety'\nimport { TillioApiError } from './errors'\nimport { assertPublicTillioApiUrl, TILLIO_URL_SUBJECT } from './url-guard'\n\nconst TILLIO_REQUEST_TIMEOUT_MS = 15_000\nconst TILLIO_MIN_REQUEST_SPACING_MS = 200\nconst TILLIO_MAX_RETRIES = 3\nconst TILLIO_RETRY_BASE_MS = 500\nconst TILLIO_RETRY_MAX_MS = 8_000\nconst TILLIO_MAX_REDIRECTS = 3\nconst RETRYABLE_STATUSES = new Set([429, 503])\nconst REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])\n\n// Tillio's rate limit is per environment, not per request, so the spacing has to be shared\n// by every client this process builds. As a closure variable it reset with each client \u2014\n// and clients are built per request \u2014 so it never actually throttled. Still per process:\n// horizontally the server-side limit stays the real backstop.\nlet lastRequestAt = 0\n\nexport type TillioClientDeps = {\n /** Test seam handed to `safeOutboundFetch`; production uses the DNS-pinned default. */\n fetchImpl?: typeof fetch\n /** Test seam for the DNS resolution that outbound URL validation performs. */\n lookupHost?: HostLookup\n}\n\n// The token travels in the query string (`DELETE /api/config` accepts it nowhere else),\n// so no URL may be pasted into an error message as-is.\nexport function redactTillioUrl(rawUrl: string): string {\n try {\n const url = new URL(rawUrl)\n if (url.searchParams.has('token')) url.searchParams.set('token', '***')\n return url.toString()\n } catch {\n return rawUrl.split('?')[0] ?? ''\n }\n}\n\nexport type TillioPlugin = 'Ringostat' | 'P4' | 'Focus' | 'Plus'\n\nexport type TillioClientEnvironment = {\n apiUrl: string\n apiKey: string\n tenantSystemId: string\n}\n\nconst addConfigResponseSchema = z.object({ token: z.string().min(1) })\nconst errorResponseSchema = z.object({\n error: z.string(),\n message: z.string().optional(),\n})\n\n// Tillio serializes pagination counters as strings (\"52\", \"1\"); tolerate numbers too.\nconst counterSchema = z.union([z.string(), z.number()]).transform((value, ctx) => {\n const parsed = Number(value)\n if (!Number.isFinite(parsed) || parsed < 0) {\n ctx.addIssue({ code: 'custom', message: 'Tillio returned an unusable pagination counter' })\n return z.NEVER\n }\n return Math.trunc(parsed)\n})\n\n// Calls stay raw records here: mapping is the normalizer's job, and `raw_snapshot`\n// must keep every field Tillio sent, including operator-specific `extraFields`.\nconst getCallsResponseSchema = z.object({\n calls: z.array(z.record(z.string(), z.unknown())).default([]),\n pagination: z.object({\n total: counterSchema,\n page: counterSchema,\n pages: counterSchema,\n }),\n})\n\nexport type TillioCallsPage = z.infer<typeof getCallsResponseSchema>\n\nexport type GetCallsParams = {\n tenantDomain: string\n token: string\n from?: string\n to?: string\n page?: number\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nfunction clampRetryAfterMs(header: string | null, fallback: number): number {\n if (!header) return fallback\n const seconds = Number(header)\n if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, TILLIO_RETRY_MAX_MS)\n return fallback\n}\n\nfunction backoffMs(attempt: number): number {\n const exponential = Math.min(TILLIO_RETRY_BASE_MS * 2 ** attempt, TILLIO_RETRY_MAX_MS)\n const jitter = Math.floor(Math.random() * TILLIO_RETRY_BASE_MS)\n return exponential + jitter\n}\n\nexport function createTillioClient(environment: TillioClientEnvironment, deps: TillioClientDeps = {}) {\n const apiUrl = environment.apiUrl.trim().replace(/\\/+$/, '')\n const apiKey = environment.apiKey.trim()\n const tenantSystemId = environment.tenantSystemId.trim()\n if (!apiUrl) throw new Error('Tillio environment is missing the API URL.')\n if (!apiKey) throw new Error('Tillio environment is missing the API key.')\n if (!tenantSystemId) throw new Error('Tillio environment is missing the tenant system id.')\n // SSRF guard: apiUrl is user-supplied, so reject loopback/private/link-local targets\n // before any request goes out (covers health check, validate, attach/detach, pull).\n assertPublicTillioApiUrl(apiUrl)\n const apiHostname = new URL(apiUrl).hostname.toLowerCase()\n\n function buildHeaders(tenantDomain: string, token?: string): Record<string, string> {\n const headers: Record<string, string> = {\n 'X-Api-Key': apiKey,\n 'X-System': tenantSystemId,\n 'X-Tenant': tenantSystemId,\n 'X-Tenant-Domain': tenantDomain,\n }\n if (token) headers['X-Token'] = token\n return headers\n }\n\n async function throttle(): Promise<void> {\n const wait = Math.max(0, TILLIO_MIN_REQUEST_SPACING_MS - (Date.now() - lastRequestAt))\n if (wait > 0) await sleep(wait)\n lastRequestAt = Date.now()\n }\n\n function resolveUrl(path: string, query?: Record<string, string>): string {\n const url = new URL(path.replace(/^\\/+/, ''), `${apiUrl}/`)\n if (query) for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value)\n return url.toString()\n }\n\n // `safeOutboundFetch` validates the URL, pins the connection to the address it validated\n // (so DNS cannot rebind between check and connect) and never follows redirects on its own.\n async function fetchOnce(\n method: string,\n url: string,\n init: { headers: Record<string, string>; body?: unknown },\n ): Promise<Response> {\n const hasBody = init.body !== undefined\n const controller = new AbortController()\n const timer = setTimeout(\n () => controller.abort(new FetchTimeoutError(redactTillioUrl(url), TILLIO_REQUEST_TIMEOUT_MS)),\n TILLIO_REQUEST_TIMEOUT_MS,\n )\n try {\n return await safeOutboundFetch(\n url,\n {\n method,\n headers: {\n accept: 'application/json',\n ...(hasBody ? { 'content-type': 'application/json' } : {}),\n ...init.headers,\n },\n ...(hasBody ? { body: JSON.stringify(init.body) } : {}),\n signal: controller.signal,\n },\n { subject: TILLIO_URL_SUBJECT, lookupHost: deps.lookupHost, fetchImpl: deps.fetchImpl },\n )\n } catch (err) {\n if ((err as { name?: string } | null)?.name === 'AbortError') {\n const reason = (controller.signal as AbortSignal & { reason?: unknown }).reason\n if (reason instanceof Error) throw reason\n }\n throw err\n } finally {\n clearTimeout(timer)\n }\n }\n\n // Every hop goes back through `fetchOnce`, so a redirect target is validated exactly like\n // the original URL. The request carries `X-Api-Key` and `X-Token`, so a hop that leaves the\n // configured host \u2014 or downgrades https to http \u2014 would hand those credentials to someone\n // else and is refused instead of followed.\n function resolveRedirectTarget(currentUrl: string, response: Response): string {\n const location = response.headers.get('location')\n if (!location) {\n throw new TillioApiError(\n `Tillio answered ${response.status} without a Location header`,\n response.status,\n 'redirect',\n )\n }\n let target: URL\n try {\n target = new URL(location, currentUrl)\n } catch {\n throw new TillioApiError(`Tillio redirected to an unusable location`, response.status, 'redirect')\n }\n const current = new URL(currentUrl)\n if (target.hostname.toLowerCase() !== apiHostname) {\n throw new TillioApiError(\n `Tillio redirected off the configured host, to \"${target.hostname}\"`,\n response.status,\n 'redirect',\n )\n }\n if (current.protocol === 'https:' && target.protocol !== 'https:') {\n throw new TillioApiError('Tillio redirected from https to a plaintext URL', response.status, 'redirect')\n }\n return target.toString()\n }\n\n async function rawRequest(\n method: string,\n url: string,\n init: { headers: Record<string, string>; body?: unknown },\n attempt = 0,\n ): Promise<Response> {\n await throttle()\n let currentMethod = method\n let currentUrl = url\n let currentInit = init\n let response: Response\n for (let hop = 0; ; hop += 1) {\n try {\n response = await fetchOnce(currentMethod, currentUrl, currentInit)\n } catch (err) {\n if (err instanceof FetchTimeoutError) {\n throw new TillioApiError(\n `Tillio request timed out: ${currentMethod} ${redactTillioUrl(currentUrl)}`,\n 0,\n 'timeout',\n )\n }\n if (err instanceof UnsafeOutboundUrlError) {\n throw new TillioApiError(`Tillio request blocked: ${err.message}`, 0, err.reason)\n }\n const message = err instanceof Error ? err.message : 'network error'\n throw new TillioApiError(\n `Tillio request failed: ${currentMethod} ${redactTillioUrl(currentUrl)}: ${message}`,\n 0,\n 'network',\n )\n }\n\n if (!REDIRECT_STATUSES.has(response.status)) break\n if (hop >= TILLIO_MAX_REDIRECTS) {\n throw new TillioApiError(\n `Tillio redirected more than ${TILLIO_MAX_REDIRECTS} times`,\n response.status,\n 'redirect',\n )\n }\n currentUrl = resolveRedirectTarget(currentUrl, response)\n // 303 means \"fetch the result with GET\", so the body must not be replayed.\n if (response.status === 303 && currentMethod !== 'GET' && currentMethod !== 'HEAD') {\n currentMethod = 'GET'\n currentInit = { headers: currentInit.headers }\n }\n }\n\n if (RETRYABLE_STATUSES.has(response.status) && attempt < TILLIO_MAX_RETRIES) {\n const delay = clampRetryAfterMs(response.headers.get('retry-after'), backoffMs(attempt))\n await sleep(delay)\n return rawRequest(method, url, init, attempt + 1)\n }\n\n return response\n }\n\n async function requestJson<T>(\n method: string,\n path: string,\n options: { headers: Record<string, string>; body?: unknown; query?: Record<string, string>; schema: z.ZodType<T> },\n ): Promise<T> {\n const url = resolveUrl(path, options.query)\n const response = await rawRequest(method, url, { headers: options.headers, body: options.body })\n const text = await response.text()\n\n let parsed: unknown\n try {\n parsed = text ? JSON.parse(text) : {}\n } catch {\n throw new TillioApiError(\n response.ok ? 'Tillio returned a non-JSON response' : `Tillio request failed (${response.status})`,\n response.status,\n text.slice(0, 200),\n )\n }\n\n const asError = errorResponseSchema.safeParse(parsed)\n if (asError.success && asError.data.error) {\n throw new TillioApiError(asError.data.message ?? asError.data.error, response.status, asError.data.error)\n }\n\n if (!response.ok) {\n throw new TillioApiError(`Tillio request failed (${response.status})`, response.status, JSON.stringify(parsed).slice(0, 200))\n }\n\n return options.schema.parse(parsed)\n }\n\n return {\n async getPlugins(tenantDomain: string): Promise<unknown> {\n return requestJson('GET', '/api/plugins', { headers: buildHeaders(tenantDomain), schema: z.unknown() })\n },\n\n async getCalls(params: GetCallsParams): Promise<TillioCallsPage> {\n const query: Record<string, string> = {}\n if (params.from) query.from = params.from\n if (params.to) query.to = params.to\n if (params.page !== undefined) query.page = String(params.page)\n return requestJson('GET', '/api/call', {\n headers: buildHeaders(params.tenantDomain, params.token),\n query,\n schema: getCallsResponseSchema,\n })\n },\n\n async validateConfig(plugin: TillioPlugin, config: Record<string, unknown>, tenantDomain: string): Promise<void> {\n await requestJson('POST', '/api/config/validate', { headers: buildHeaders(tenantDomain), body: { plugin, config }, schema: z.unknown() })\n },\n\n async addConfig(plugin: TillioPlugin, config: Record<string, unknown>, tenantDomain: string): Promise<{ token: string }> {\n return requestJson('POST', '/api/config', { headers: buildHeaders(tenantDomain), body: { plugin, config }, schema: addConfigResponseSchema })\n },\n\n async deleteConfig(plugin: TillioPlugin, token: string, tenantDomain: string): Promise<void> {\n await requestJson('DELETE', '/api/config', { headers: buildHeaders(tenantDomain), query: { plugin, token }, schema: z.unknown() })\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { isValidTimeZone } from "./tz.js";
|
|
4
|
+
const TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID = "tillio_environment";
|
|
5
|
+
const ENV_PROBE_TENANT_DOMAIN = "test_connection";
|
|
6
|
+
const environmentSchema = z.object({
|
|
7
|
+
apiUrl: z.string().trim().min(1),
|
|
8
|
+
apiKey: z.string().trim().min(1),
|
|
9
|
+
tenantSystemId: z.string().trim().optional(),
|
|
10
|
+
// Optional so an environment saved before this field existed still parses; readers fall back to
|
|
11
|
+
// TILLIO_TIMEZONE.
|
|
12
|
+
timeZone: z.string().trim().refine(isValidTimeZone, { message: "Expected an IANA time zone name" }).optional()
|
|
13
|
+
});
|
|
14
|
+
const identitySchema = z.object({ tenantSystemId: z.string().trim().min(1) });
|
|
15
|
+
function generateTenantSystemId() {
|
|
16
|
+
return `OM-${randomBytes(6).toString("hex")}`;
|
|
17
|
+
}
|
|
18
|
+
async function readTenantSystemId(credentialsService, scope) {
|
|
19
|
+
const raw = await credentialsService.getRaw(TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID, scope);
|
|
20
|
+
const parsed = identitySchema.safeParse(raw ?? {});
|
|
21
|
+
return parsed.success ? parsed.data.tenantSystemId : null;
|
|
22
|
+
}
|
|
23
|
+
async function saveTenantSystemId(credentialsService, scope, tenantSystemId) {
|
|
24
|
+
await credentialsService.save(TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID, { tenantSystemId }, scope);
|
|
25
|
+
}
|
|
26
|
+
export {
|
|
27
|
+
ENV_PROBE_TENANT_DOMAIN,
|
|
28
|
+
TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID,
|
|
29
|
+
environmentSchema,
|
|
30
|
+
generateTenantSystemId,
|
|
31
|
+
readTenantSystemId,
|
|
32
|
+
saveTenantSystemId
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=environment.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/tillio/lib/environment.ts"],
|
|
4
|
+
"sourcesContent": ["import { randomBytes } from 'node:crypto'\nimport { z } from 'zod'\nimport type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'\nimport { isValidTimeZone } from './tz'\n\n// Own key, like the operators blob: saving integration credentials keeps the submitted fields\n// plus the schema's secret fields, and the identity is in neither, so it was dropped on every save.\nexport const TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID = 'tillio_environment'\n\nexport const ENV_PROBE_TENANT_DOMAIN = 'test_connection'\n\nexport const environmentSchema = z.object({\n apiUrl: z.string().trim().min(1),\n apiKey: z.string().trim().min(1),\n tenantSystemId: z.string().trim().optional(),\n // Optional so an environment saved before this field existed still parses; readers fall back to\n // TILLIO_TIMEZONE.\n timeZone: z\n .string()\n .trim()\n .refine(isValidTimeZone, { message: 'Expected an IANA time zone name' })\n .optional(),\n})\n\nexport type TillioEnvironment = z.infer<typeof environmentSchema>\n\nconst identitySchema = z.object({ tenantSystemId: z.string().trim().min(1) })\n\nexport type TillioIdentityStore = {\n getRaw: (integrationId: string, scope: IntegrationScope) => Promise<Record<string, unknown> | null>\n save: (integrationId: string, credentials: Record<string, unknown>, scope: IntegrationScope) => Promise<void>\n}\n\nexport function generateTenantSystemId(): string {\n return `OM-${randomBytes(6).toString('hex')}`\n}\n\nexport async function readTenantSystemId(\n credentialsService: TillioIdentityStore,\n scope: IntegrationScope,\n): Promise<string | null> {\n const raw = await credentialsService.getRaw(TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID, scope)\n const parsed = identitySchema.safeParse(raw ?? {})\n return parsed.success ? parsed.data.tenantSystemId : null\n}\n\nexport async function saveTenantSystemId(\n credentialsService: TillioIdentityStore,\n scope: IntegrationScope,\n tenantSystemId: string,\n): Promise<void> {\n await credentialsService.save(TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID, { tenantSystemId }, scope)\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,mBAAmB;AAC5B,SAAS,SAAS;AAElB,SAAS,uBAAuB;AAIzB,MAAM,6CAA6C;AAEnD,MAAM,0BAA0B;AAEhC,MAAM,oBAAoB,EAAE,OAAO;AAAA,EACxC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC;AAAA,EAC/B,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC;AAAA,EAC/B,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA;AAAA;AAAA,EAG3C,UAAU,EACP,OAAO,EACP,KAAK,EACL,OAAO,iBAAiB,EAAE,SAAS,kCAAkC,CAAC,EACtE,SAAS;AACd,CAAC;AAID,MAAM,iBAAiB,EAAE,OAAO,EAAE,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,EAAE,CAAC;AAOrE,SAAS,yBAAiC;AAC/C,SAAO,MAAM,YAAY,CAAC,EAAE,SAAS,KAAK,CAAC;AAC7C;AAEA,eAAsB,mBACpB,oBACA,OACwB;AACxB,QAAM,MAAM,MAAM,mBAAmB,OAAO,4CAA4C,KAAK;AAC7F,QAAM,SAAS,eAAe,UAAU,OAAO,CAAC,CAAC;AACjD,SAAO,OAAO,UAAU,OAAO,KAAK,iBAAiB;AACvD;AAEA,eAAsB,mBACpB,oBACA,OACA,gBACe;AACf,QAAM,mBAAmB,KAAK,4CAA4C,EAAE,eAAe,GAAG,KAAK;AACrG;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const TILLIO_ERROR_CODES = [
|
|
2
|
+
"unauthorized",
|
|
3
|
+
"invalid_payload",
|
|
4
|
+
"invalid_operator_id",
|
|
5
|
+
"integration_disabled",
|
|
6
|
+
"environment_not_ready",
|
|
7
|
+
"operator_missing",
|
|
8
|
+
"environment_drift",
|
|
9
|
+
"pull_already_running",
|
|
10
|
+
"pull_failed",
|
|
11
|
+
"app_url_missing",
|
|
12
|
+
"operator_limit",
|
|
13
|
+
"provider_error",
|
|
14
|
+
"revocation_failed",
|
|
15
|
+
"attach_failed",
|
|
16
|
+
"detach_failed"
|
|
17
|
+
];
|
|
18
|
+
const COPY = {
|
|
19
|
+
unauthorized: { key: "tillio.errors.unauthorized", fallback: "Your session expired. Sign in again." },
|
|
20
|
+
invalid_payload: { key: "tillio.errors.invalidPayload", fallback: "The request was rejected as invalid." },
|
|
21
|
+
invalid_operator_id: { key: "tillio.errors.invalidOperatorId", fallback: "That operator id is not valid." },
|
|
22
|
+
integration_disabled: { key: "tillio.errors.integrationDisabled", fallback: "The Tillio integration is disabled. Enable it first." },
|
|
23
|
+
environment_not_ready: { key: "tillio.errors.environmentNotReady", fallback: "Configure the Tillio credentials and run the health check first." },
|
|
24
|
+
operator_missing: { key: "tillio.errors.operatorMissing", fallback: "Attach a Tillio operator first." },
|
|
25
|
+
environment_drift: { key: "tillio.errors.environmentDrift", fallback: "The environment changed after the operator was attached. Detach and attach it again." },
|
|
26
|
+
pull_already_running: { key: "tillio.errors.pullAlreadyRunning", fallback: "A pull is already running. Wait for it to finish." },
|
|
27
|
+
pull_failed: { key: "tillio.errors.pullFailed", fallback: "Could not pull calls from Tillio." },
|
|
28
|
+
app_url_missing: { key: "tillio.errors.appUrlMissing", fallback: "APP_URL is not configured, so the operator webhook domain cannot be derived." },
|
|
29
|
+
operator_limit: { key: "tillio.errors.operatorLimit", fallback: "An operator is already attached. Detach it first." },
|
|
30
|
+
provider_error: { key: "tillio.errors.providerError", fallback: "Tillio rejected the request." },
|
|
31
|
+
revocation_failed: { key: "tillio.errors.revocationFailed", fallback: "The Tillio token could not be revoked." },
|
|
32
|
+
attach_failed: { key: "tillio.errors.attachFailed", fallback: "Could not attach the operator." },
|
|
33
|
+
detach_failed: { key: "tillio.errors.detachFailed", fallback: "Could not detach the operator." }
|
|
34
|
+
};
|
|
35
|
+
function tillioErrorCopy(code, fallbackCode) {
|
|
36
|
+
const known = TILLIO_ERROR_CODES.find((entry) => entry === code);
|
|
37
|
+
return COPY[known ?? fallbackCode];
|
|
38
|
+
}
|
|
39
|
+
const PULL_BLOCKER_COPY = {
|
|
40
|
+
integration_disabled: COPY.integration_disabled,
|
|
41
|
+
environment_not_ready: {
|
|
42
|
+
key: "tillio.pull.envNotReady",
|
|
43
|
+
fallback: "Configure the Tillio credentials and run the health check on the integration page first."
|
|
44
|
+
},
|
|
45
|
+
operator_missing: {
|
|
46
|
+
key: "tillio.pull.operatorMissing",
|
|
47
|
+
fallback: "Attach a Tillio operator on the integration page before pulling calls."
|
|
48
|
+
},
|
|
49
|
+
environment_drift: {
|
|
50
|
+
key: "tillio.pull.envDrift",
|
|
51
|
+
fallback: "The Tillio environment changed after the operator was attached. Detach and attach it again before pulling calls."
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
export {
|
|
55
|
+
PULL_BLOCKER_COPY,
|
|
56
|
+
TILLIO_ERROR_CODES,
|
|
57
|
+
tillioErrorCopy
|
|
58
|
+
};
|
|
59
|
+
//# sourceMappingURL=error-codes.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/tillio/lib/error-codes.ts"],
|
|
4
|
+
"sourcesContent": ["import type { PullBlocker } from './pull-readiness'\n\n// Routes answer with a stable code and the widgets own the wording, so a non-English user never\n// sees a server string. `message` stays in the payload for API consumers and logs only.\nexport const TILLIO_ERROR_CODES = [\n 'unauthorized',\n 'invalid_payload',\n 'invalid_operator_id',\n 'integration_disabled',\n 'environment_not_ready',\n 'operator_missing',\n 'environment_drift',\n 'pull_already_running',\n 'pull_failed',\n 'app_url_missing',\n 'operator_limit',\n 'provider_error',\n 'revocation_failed',\n 'attach_failed',\n 'detach_failed',\n] as const\n\nexport type TillioErrorCode = typeof TILLIO_ERROR_CODES[number]\n\ntype TillioErrorCopy = { key: string; fallback: string }\n\nconst COPY: Record<TillioErrorCode, TillioErrorCopy> = {\n unauthorized: { key: 'tillio.errors.unauthorized', fallback: 'Your session expired. Sign in again.' },\n invalid_payload: { key: 'tillio.errors.invalidPayload', fallback: 'The request was rejected as invalid.' },\n invalid_operator_id: { key: 'tillio.errors.invalidOperatorId', fallback: 'That operator id is not valid.' },\n integration_disabled: { key: 'tillio.errors.integrationDisabled', fallback: 'The Tillio integration is disabled. Enable it first.' },\n environment_not_ready: { key: 'tillio.errors.environmentNotReady', fallback: 'Configure the Tillio credentials and run the health check first.' },\n operator_missing: { key: 'tillio.errors.operatorMissing', fallback: 'Attach a Tillio operator first.' },\n environment_drift: { key: 'tillio.errors.environmentDrift', fallback: 'The environment changed after the operator was attached. Detach and attach it again.' },\n pull_already_running: { key: 'tillio.errors.pullAlreadyRunning', fallback: 'A pull is already running. Wait for it to finish.' },\n pull_failed: { key: 'tillio.errors.pullFailed', fallback: 'Could not pull calls from Tillio.' },\n app_url_missing: { key: 'tillio.errors.appUrlMissing', fallback: 'APP_URL is not configured, so the operator webhook domain cannot be derived.' },\n operator_limit: { key: 'tillio.errors.operatorLimit', fallback: 'An operator is already attached. Detach it first.' },\n provider_error: { key: 'tillio.errors.providerError', fallback: 'Tillio rejected the request.' },\n revocation_failed: { key: 'tillio.errors.revocationFailed', fallback: 'The Tillio token could not be revoked.' },\n attach_failed: { key: 'tillio.errors.attachFailed', fallback: 'Could not attach the operator.' },\n detach_failed: { key: 'tillio.errors.detachFailed', fallback: 'Could not detach the operator.' },\n}\n\nexport function tillioErrorCopy(code: string | null | undefined, fallbackCode: TillioErrorCode): TillioErrorCopy {\n const known = TILLIO_ERROR_CODES.find((entry) => entry === code)\n return COPY[known ?? fallbackCode]\n}\n\n// Keyed by the shared union so a blocker the server learns to report cannot reach the dialog\n// unworded: adding one to `PullBlocker` fails this record until it gets copy of its own. The pull\n// entries are worded for the action in progress, which the generic `COPY` above cannot be.\nexport const PULL_BLOCKER_COPY: Record<PullBlocker, TillioErrorCopy> = {\n integration_disabled: COPY.integration_disabled,\n environment_not_ready: {\n key: 'tillio.pull.envNotReady',\n fallback: 'Configure the Tillio credentials and run the health check on the integration page first.',\n },\n operator_missing: {\n key: 'tillio.pull.operatorMissing',\n fallback: 'Attach a Tillio operator on the integration page before pulling calls.',\n },\n environment_drift: {\n key: 'tillio.pull.envDrift',\n fallback: 'The Tillio environment changed after the operator was attached. Detach and attach it again before pulling calls.',\n },\n}\n"],
|
|
5
|
+
"mappings": "AAIO,MAAM,qBAAqB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAMA,MAAM,OAAiD;AAAA,EACrD,cAAc,EAAE,KAAK,8BAA8B,UAAU,uCAAuC;AAAA,EACpG,iBAAiB,EAAE,KAAK,gCAAgC,UAAU,uCAAuC;AAAA,EACzG,qBAAqB,EAAE,KAAK,mCAAmC,UAAU,iCAAiC;AAAA,EAC1G,sBAAsB,EAAE,KAAK,qCAAqC,UAAU,uDAAuD;AAAA,EACnI,uBAAuB,EAAE,KAAK,qCAAqC,UAAU,mEAAmE;AAAA,EAChJ,kBAAkB,EAAE,KAAK,iCAAiC,UAAU,kCAAkC;AAAA,EACtG,mBAAmB,EAAE,KAAK,kCAAkC,UAAU,uFAAuF;AAAA,EAC7J,sBAAsB,EAAE,KAAK,oCAAoC,UAAU,oDAAoD;AAAA,EAC/H,aAAa,EAAE,KAAK,4BAA4B,UAAU,oCAAoC;AAAA,EAC9F,iBAAiB,EAAE,KAAK,+BAA+B,UAAU,+EAA+E;AAAA,EAChJ,gBAAgB,EAAE,KAAK,+BAA+B,UAAU,oDAAoD;AAAA,EACpH,gBAAgB,EAAE,KAAK,+BAA+B,UAAU,+BAA+B;AAAA,EAC/F,mBAAmB,EAAE,KAAK,kCAAkC,UAAU,yCAAyC;AAAA,EAC/G,eAAe,EAAE,KAAK,8BAA8B,UAAU,iCAAiC;AAAA,EAC/F,eAAe,EAAE,KAAK,8BAA8B,UAAU,iCAAiC;AACjG;AAEO,SAAS,gBAAgB,MAAiC,cAAgD;AAC/G,QAAM,QAAQ,mBAAmB,KAAK,CAAC,UAAU,UAAU,IAAI;AAC/D,SAAO,KAAK,SAAS,YAAY;AACnC;AAKO,MAAM,oBAA0D;AAAA,EACrE,sBAAsB,KAAK;AAAA,EAC3B,uBAAuB;AAAA,IACrB,KAAK;AAAA,IACL,UAAU;AAAA,EACZ;AAAA,EACA,kBAAkB;AAAA,IAChB,KAAK;AAAA,IACL,UAAU;AAAA,EACZ;AAAA,EACA,mBAAmB;AAAA,IACjB,KAAK;AAAA,IACL,UAAU;AAAA,EACZ;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/tillio/lib/errors.ts"],
|
|
4
|
+
"sourcesContent": ["export class TillioApiError extends Error {\n readonly status: number\n readonly detail: string\n\n constructor(message: string, status: number, detail: string) {\n super(message)\n this.name = 'TillioApiError'\n this.status = status\n this.detail = detail\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,uBAAuB,MAAM;AAAA,EAIxC,YAAY,SAAiB,QAAgB,QAAgB;AAC3D,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,SAAS;AACd,SAAK,SAAS;AAAA,EAChB;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { TillioApiError } from "./errors.js";
|
|
2
|
+
import { createTillioClient } from "./client.js";
|
|
3
|
+
import {
|
|
4
|
+
ENV_PROBE_TENANT_DOMAIN,
|
|
5
|
+
environmentSchema,
|
|
6
|
+
generateTenantSystemId,
|
|
7
|
+
readTenantSystemId,
|
|
8
|
+
saveTenantSystemId
|
|
9
|
+
} from "./environment.js";
|
|
10
|
+
function createTillioEnvironmentHealthCheck(deps) {
|
|
11
|
+
return {
|
|
12
|
+
async check(credentials, scope) {
|
|
13
|
+
const parsed = environmentSchema.safeParse(credentials ?? {});
|
|
14
|
+
if (!parsed.success) {
|
|
15
|
+
return { status: "unhealthy", message: "Environment is not configured (set the Tillio API URL and API key)." };
|
|
16
|
+
}
|
|
17
|
+
let tenantSystemId = await readTenantSystemId(deps.credentialsService, scope);
|
|
18
|
+
if (!tenantSystemId) {
|
|
19
|
+
tenantSystemId = parsed.data.tenantSystemId ?? generateTenantSystemId();
|
|
20
|
+
try {
|
|
21
|
+
await saveTenantSystemId(deps.credentialsService, scope, tenantSystemId);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
const message = err instanceof Error ? err.message : "Failed to persist the environment identity";
|
|
24
|
+
return { status: "unhealthy", message };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const client = createTillioClient({ apiUrl: parsed.data.apiUrl, apiKey: parsed.data.apiKey, tenantSystemId });
|
|
29
|
+
await client.getPlugins(ENV_PROBE_TENANT_DOMAIN);
|
|
30
|
+
return { status: "healthy", message: "Tillio environment connected." };
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const message = err instanceof TillioApiError || err instanceof Error ? err.message : "Environment check failed";
|
|
33
|
+
return { status: "unhealthy", message };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export {
|
|
39
|
+
createTillioEnvironmentHealthCheck
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=health.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/tillio/lib/health.ts"],
|
|
4
|
+
"sourcesContent": ["import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'\nimport { TillioApiError } from './errors'\nimport { createTillioClient } from './client'\nimport {\n ENV_PROBE_TENANT_DOMAIN,\n environmentSchema,\n generateTenantSystemId,\n readTenantSystemId,\n saveTenantSystemId,\n type TillioIdentityStore,\n} from './environment'\n\nexport type CredentialsServiceLike = TillioIdentityStore\n\ntype HealthResult = {\n status: 'healthy' | 'degraded' | 'unhealthy'\n message?: string\n details?: Record<string, unknown>\n}\n\nexport function createTillioEnvironmentHealthCheck(deps: { credentialsService: CredentialsServiceLike }) {\n return {\n async check(credentials: Record<string, unknown> | null, scope: IntegrationScope): Promise<HealthResult> {\n const parsed = environmentSchema.safeParse(credentials ?? {})\n if (!parsed.success) {\n return { status: 'unhealthy', message: 'Environment is not configured (set the Tillio API URL and API key).' }\n }\n\n // Writes on first run: the client sends this id as X-System/X-Tenant and refuses to build\n // without one, so the probe below cannot run before it exists. A subscriber on\n // `integrations.credentials.updated` would leave a window where Check reports \"not configured\".\n let tenantSystemId = await readTenantSystemId(deps.credentialsService, scope)\n if (!tenantSystemId) {\n // Adopts an identity still sitting in the legacy record before minting a new one:\n // a fresh id would change X-System/X-Tenant and invalidate every operator token.\n tenantSystemId = parsed.data.tenantSystemId ?? generateTenantSystemId()\n try {\n await saveTenantSystemId(deps.credentialsService, scope, tenantSystemId)\n } catch (err) {\n const message = err instanceof Error ? err.message : 'Failed to persist the environment identity'\n return { status: 'unhealthy', message }\n }\n }\n\n try {\n const client = createTillioClient({ apiUrl: parsed.data.apiUrl, apiKey: parsed.data.apiKey, tenantSystemId })\n // No dedicated ping endpoint, so listing plugins stands in for one: read-only and over the\n // same auth path a real call takes. The probe domain is a throwaway \u2014 no operator exists yet.\n await client.getPlugins(ENV_PROBE_TENANT_DOMAIN)\n return { status: 'healthy', message: 'Tillio environment connected.' }\n } catch (err) {\n const message = err instanceof TillioApiError || err instanceof Error ? err.message : 'Environment check failed'\n return { status: 'unhealthy', message }\n }\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,sBAAsB;AAC/B,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AAUA,SAAS,mCAAmC,MAAsD;AACvG,SAAO;AAAA,IACL,MAAM,MAAM,aAA6C,OAAgD;AACvG,YAAM,SAAS,kBAAkB,UAAU,eAAe,CAAC,CAAC;AAC5D,UAAI,CAAC,OAAO,SAAS;AACnB,eAAO,EAAE,QAAQ,aAAa,SAAS,sEAAsE;AAAA,MAC/G;AAKA,UAAI,iBAAiB,MAAM,mBAAmB,KAAK,oBAAoB,KAAK;AAC5E,UAAI,CAAC,gBAAgB;AAGnB,yBAAiB,OAAO,KAAK,kBAAkB,uBAAuB;AACtE,YAAI;AACF,gBAAM,mBAAmB,KAAK,oBAAoB,OAAO,cAAc;AAAA,QACzE,SAAS,KAAK;AACZ,gBAAM,UAAU,eAAe,QAAQ,IAAI,UAAU;AACrD,iBAAO,EAAE,QAAQ,aAAa,QAAQ;AAAA,QACxC;AAAA,MACF;AAEA,UAAI;AACF,cAAM,SAAS,mBAAmB,EAAE,QAAQ,OAAO,KAAK,QAAQ,QAAQ,OAAO,KAAK,QAAQ,eAAe,CAAC;AAG5G,cAAM,OAAO,WAAW,uBAAuB;AAC/C,eAAO,EAAE,QAAQ,WAAW,SAAS,gCAAgC;AAAA,MACvE,SAAS,KAAK;AACZ,cAAM,UAAU,eAAe,kBAAkB,eAAe,QAAQ,IAAI,UAAU;AACtF,eAAO,EAAE,QAAQ,aAAa,QAAQ;AAAA,MACxC;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
function createTillioLock(em, key) {
|
|
2
|
+
return async (run) => em.transactional(async (tx) => {
|
|
3
|
+
await tx.getConnection().execute("select pg_advisory_xact_lock(hashtext(?::text))", [key]);
|
|
4
|
+
return run();
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
function tillioOperatorLockKey(scope) {
|
|
8
|
+
return `tillio:operators:${scope.tenantId}:${scope.organizationId}`;
|
|
9
|
+
}
|
|
10
|
+
function tillioPullLockKey(scope) {
|
|
11
|
+
return `tillio:pull:${scope.tenantId}:${scope.organizationId}`;
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
createTillioLock,
|
|
15
|
+
tillioOperatorLockKey,
|
|
16
|
+
tillioPullLockKey
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=locking.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/tillio/lib/locking.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'\n\n/**\n * Runs `run` with a scope-wide lock held. Both call sites read state, talk to Tillio and then\n * write, and neither store underneath them offers a compare-and-set, so re-checking before the\n * write can only narrow the race, never close it.\n *\n * `pg_advisory_xact_lock` is the same primitive `attachments`, `notifications` and `sso` use, and\n * it releases with the transaction, so a request that dies mid-section cannot leave it held. The\n * transaction exists to own the lock: the work inside writes through its own services and is not\n * made atomic by it.\n */\nexport type TillioLockRunner = <T>(run: () => Promise<T>) => Promise<T>\n\nexport function createTillioLock(em: EntityManager, key: string): TillioLockRunner {\n return async <T>(run: () => Promise<T>): Promise<T> =>\n em.transactional(async (tx) => {\n await tx.getConnection().execute('select pg_advisory_xact_lock(hashtext(?::text))', [key])\n return run()\n })\n}\n\nexport function tillioOperatorLockKey(scope: IntegrationScope): string {\n return `tillio:operators:${scope.tenantId}:${scope.organizationId}`\n}\n\nexport function tillioPullLockKey(scope: IntegrationScope): string {\n return `tillio:pull:${scope.tenantId}:${scope.organizationId}`\n}\n"],
|
|
5
|
+
"mappings": "AAeO,SAAS,iBAAiB,IAAmB,KAA+B;AACjF,SAAO,OAAU,QACf,GAAG,cAAc,OAAO,OAAO;AAC7B,UAAM,GAAG,cAAc,EAAE,QAAQ,mDAAmD,CAAC,GAAG,CAAC;AACzF,WAAO,IAAI;AAAA,EACb,CAAC;AACL;AAEO,SAAS,sBAAsB,OAAiC;AACrE,SAAO,oBAAoB,MAAM,QAAQ,IAAI,MAAM,cAAc;AACnE;AAEO,SAAS,kBAAkB,OAAiC;AACjE,SAAO,eAAe,MAAM,QAAQ,IAAI,MAAM,cAAc;AAC9D;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|