@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.
Files changed (135) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/build.mjs +7 -0
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +7 -0
  5. package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js +17 -0
  6. package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js.map +7 -0
  7. package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js +27 -0
  8. package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js.map +7 -0
  9. package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js +47 -0
  10. package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js.map +7 -0
  11. package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js +11 -0
  12. package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js.map +7 -0
  13. package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js +13 -0
  14. package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js.map +7 -0
  15. package/dist/modules/tillio/acl.js +9 -0
  16. package/dist/modules/tillio/acl.js.map +7 -0
  17. package/dist/modules/tillio/api/operators/[id]/route.js +78 -0
  18. package/dist/modules/tillio/api/operators/[id]/route.js.map +7 -0
  19. package/dist/modules/tillio/api/operators/route.js +171 -0
  20. package/dist/modules/tillio/api/operators/route.js.map +7 -0
  21. package/dist/modules/tillio/api/pull/route.js +180 -0
  22. package/dist/modules/tillio/api/pull/route.js.map +7 -0
  23. package/dist/modules/tillio/cli.js +108 -0
  24. package/dist/modules/tillio/cli.js.map +7 -0
  25. package/dist/modules/tillio/di.js +16 -0
  26. package/dist/modules/tillio/di.js.map +7 -0
  27. package/dist/modules/tillio/index.js +12 -0
  28. package/dist/modules/tillio/index.js.map +7 -0
  29. package/dist/modules/tillio/integration.js +62 -0
  30. package/dist/modules/tillio/integration.js.map +7 -0
  31. package/dist/modules/tillio/lib/adapter.js +107 -0
  32. package/dist/modules/tillio/lib/adapter.js.map +7 -0
  33. package/dist/modules/tillio/lib/client.js +249 -0
  34. package/dist/modules/tillio/lib/client.js.map +7 -0
  35. package/dist/modules/tillio/lib/environment.js +34 -0
  36. package/dist/modules/tillio/lib/environment.js.map +7 -0
  37. package/dist/modules/tillio/lib/error-codes.js +59 -0
  38. package/dist/modules/tillio/lib/error-codes.js.map +7 -0
  39. package/dist/modules/tillio/lib/errors.js +12 -0
  40. package/dist/modules/tillio/lib/errors.js.map +7 -0
  41. package/dist/modules/tillio/lib/health.js +41 -0
  42. package/dist/modules/tillio/lib/health.js.map +7 -0
  43. package/dist/modules/tillio/lib/locking.js +18 -0
  44. package/dist/modules/tillio/lib/locking.js.map +7 -0
  45. package/dist/modules/tillio/lib/normalizer.js +134 -0
  46. package/dist/modules/tillio/lib/normalizer.js.map +7 -0
  47. package/dist/modules/tillio/lib/operators-store.js +62 -0
  48. package/dist/modules/tillio/lib/operators-store.js.map +7 -0
  49. package/dist/modules/tillio/lib/operators.js +168 -0
  50. package/dist/modules/tillio/lib/operators.js.map +7 -0
  51. package/dist/modules/tillio/lib/preset.js +122 -0
  52. package/dist/modules/tillio/lib/preset.js.map +7 -0
  53. package/dist/modules/tillio/lib/pull-job.js +265 -0
  54. package/dist/modules/tillio/lib/pull-job.js.map +7 -0
  55. package/dist/modules/tillio/lib/pull-readiness.js +36 -0
  56. package/dist/modules/tillio/lib/pull-readiness.js.map +7 -0
  57. package/dist/modules/tillio/lib/queue.js +22 -0
  58. package/dist/modules/tillio/lib/queue.js.map +7 -0
  59. package/dist/modules/tillio/lib/tz.js +85 -0
  60. package/dist/modules/tillio/lib/tz.js.map +7 -0
  61. package/dist/modules/tillio/lib/url-guard.js +24 -0
  62. package/dist/modules/tillio/lib/url-guard.js.map +7 -0
  63. package/dist/modules/tillio/setup.js +45 -0
  64. package/dist/modules/tillio/setup.js.map +7 -0
  65. package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js +208 -0
  66. package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js.map +7 -0
  67. package/dist/modules/tillio/widgets/injection/operators-config/widget.js +17 -0
  68. package/dist/modules/tillio/widgets/injection/operators-config/widget.js.map +7 -0
  69. package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js +148 -0
  70. package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js.map +7 -0
  71. package/dist/modules/tillio/widgets/injection/pull-calls/widget.js +17 -0
  72. package/dist/modules/tillio/widgets/injection/pull-calls/widget.js.map +7 -0
  73. package/dist/modules/tillio/widgets/injection-table.js +21 -0
  74. package/dist/modules/tillio/widgets/injection-table.js.map +7 -0
  75. package/dist/modules/tillio/workers/tillio-pull.js +17 -0
  76. package/dist/modules/tillio/workers/tillio-pull.js.map +7 -0
  77. package/jest.config.cjs +35 -0
  78. package/package.json +92 -0
  79. package/src/index.ts +1 -0
  80. package/src/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.ts +38 -0
  81. package/src/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.ts +44 -0
  82. package/src/modules/tillio/__integration__/TC-PHONE-003-operators.spec.ts +76 -0
  83. package/src/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.ts +22 -0
  84. package/src/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.ts +27 -0
  85. package/src/modules/tillio/__tests__/adapter-fetch-calls.test.ts +148 -0
  86. package/src/modules/tillio/__tests__/client.test.ts +203 -0
  87. package/src/modules/tillio/__tests__/health.test.ts +122 -0
  88. package/src/modules/tillio/__tests__/normalizer.test.ts +143 -0
  89. package/src/modules/tillio/__tests__/operators-routes.test.ts +100 -0
  90. package/src/modules/tillio/__tests__/operators-store.test.ts +104 -0
  91. package/src/modules/tillio/__tests__/operators.test.ts +250 -0
  92. package/src/modules/tillio/__tests__/preset.test.ts +337 -0
  93. package/src/modules/tillio/__tests__/pull-body.test.ts +43 -0
  94. package/src/modules/tillio/__tests__/pull-job.test.ts +250 -0
  95. package/src/modules/tillio/__tests__/pull-readiness.test.ts +143 -0
  96. package/src/modules/tillio/__tests__/pull-route.test.ts +148 -0
  97. package/src/modules/tillio/__tests__/tz.test.ts +53 -0
  98. package/src/modules/tillio/__tests__/url-guard.test.ts +46 -0
  99. package/src/modules/tillio/acl.ts +5 -0
  100. package/src/modules/tillio/api/operators/[id]/route.ts +90 -0
  101. package/src/modules/tillio/api/operators/route.ts +191 -0
  102. package/src/modules/tillio/api/pull/route.ts +213 -0
  103. package/src/modules/tillio/cli.ts +125 -0
  104. package/src/modules/tillio/di.ts +16 -0
  105. package/src/modules/tillio/i18n/de.json +73 -0
  106. package/src/modules/tillio/i18n/en.json +73 -0
  107. package/src/modules/tillio/i18n/es.json +73 -0
  108. package/src/modules/tillio/i18n/ko.json +73 -0
  109. package/src/modules/tillio/i18n/pl.json +73 -0
  110. package/src/modules/tillio/index.ts +8 -0
  111. package/src/modules/tillio/integration.ts +58 -0
  112. package/src/modules/tillio/lib/adapter.ts +135 -0
  113. package/src/modules/tillio/lib/client.ts +329 -0
  114. package/src/modules/tillio/lib/environment.ts +53 -0
  115. package/src/modules/tillio/lib/error-codes.ts +67 -0
  116. package/src/modules/tillio/lib/errors.ts +11 -0
  117. package/src/modules/tillio/lib/health.ts +57 -0
  118. package/src/modules/tillio/lib/locking.ts +30 -0
  119. package/src/modules/tillio/lib/normalizer.ts +184 -0
  120. package/src/modules/tillio/lib/operators-store.ts +77 -0
  121. package/src/modules/tillio/lib/operators.ts +269 -0
  122. package/src/modules/tillio/lib/preset.ts +226 -0
  123. package/src/modules/tillio/lib/pull-job.ts +390 -0
  124. package/src/modules/tillio/lib/pull-readiness.ts +77 -0
  125. package/src/modules/tillio/lib/queue.ts +33 -0
  126. package/src/modules/tillio/lib/tz.ts +121 -0
  127. package/src/modules/tillio/lib/url-guard.ts +34 -0
  128. package/src/modules/tillio/setup.ts +49 -0
  129. package/src/modules/tillio/widgets/injection/operators-config/widget.client.tsx +304 -0
  130. package/src/modules/tillio/widgets/injection/operators-config/widget.ts +16 -0
  131. package/src/modules/tillio/widgets/injection/pull-calls/widget.client.tsx +214 -0
  132. package/src/modules/tillio/widgets/injection/pull-calls/widget.ts +16 -0
  133. package/src/modules/tillio/widgets/injection-table.ts +19 -0
  134. package/src/modules/tillio/workers/tillio-pull.ts +18 -0
  135. package/tsconfig.json +9 -0
@@ -0,0 +1,329 @@
1
+ import { z } from 'zod'
2
+ import { FetchTimeoutError } from '@open-mercato/shared/lib/http/fetchWithTimeout'
3
+ import { safeOutboundFetch, UnsafeOutboundUrlError, type HostLookup } from '@open-mercato/shared/lib/url-safety'
4
+ import { TillioApiError } from './errors'
5
+ import { assertPublicTillioApiUrl, TILLIO_URL_SUBJECT } from './url-guard'
6
+
7
+ const TILLIO_REQUEST_TIMEOUT_MS = 15_000
8
+ const TILLIO_MIN_REQUEST_SPACING_MS = 200
9
+ const TILLIO_MAX_RETRIES = 3
10
+ const TILLIO_RETRY_BASE_MS = 500
11
+ const TILLIO_RETRY_MAX_MS = 8_000
12
+ const TILLIO_MAX_REDIRECTS = 3
13
+ const RETRYABLE_STATUSES = new Set([429, 503])
14
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
15
+
16
+ // Tillio's rate limit is per environment, not per request, so the spacing has to be shared
17
+ // by every client this process builds. As a closure variable it reset with each client —
18
+ // and clients are built per request — so it never actually throttled. Still per process:
19
+ // horizontally the server-side limit stays the real backstop.
20
+ let lastRequestAt = 0
21
+
22
+ export type TillioClientDeps = {
23
+ /** Test seam handed to `safeOutboundFetch`; production uses the DNS-pinned default. */
24
+ fetchImpl?: typeof fetch
25
+ /** Test seam for the DNS resolution that outbound URL validation performs. */
26
+ lookupHost?: HostLookup
27
+ }
28
+
29
+ // The token travels in the query string (`DELETE /api/config` accepts it nowhere else),
30
+ // so no URL may be pasted into an error message as-is.
31
+ export function redactTillioUrl(rawUrl: string): string {
32
+ try {
33
+ const url = new URL(rawUrl)
34
+ if (url.searchParams.has('token')) url.searchParams.set('token', '***')
35
+ return url.toString()
36
+ } catch {
37
+ return rawUrl.split('?')[0] ?? ''
38
+ }
39
+ }
40
+
41
+ export type TillioPlugin = 'Ringostat' | 'P4' | 'Focus' | 'Plus'
42
+
43
+ export type TillioClientEnvironment = {
44
+ apiUrl: string
45
+ apiKey: string
46
+ tenantSystemId: string
47
+ }
48
+
49
+ const addConfigResponseSchema = z.object({ token: z.string().min(1) })
50
+ const errorResponseSchema = z.object({
51
+ error: z.string(),
52
+ message: z.string().optional(),
53
+ })
54
+
55
+ // Tillio serializes pagination counters as strings ("52", "1"); tolerate numbers too.
56
+ const counterSchema = z.union([z.string(), z.number()]).transform((value, ctx) => {
57
+ const parsed = Number(value)
58
+ if (!Number.isFinite(parsed) || parsed < 0) {
59
+ ctx.addIssue({ code: 'custom', message: 'Tillio returned an unusable pagination counter' })
60
+ return z.NEVER
61
+ }
62
+ return Math.trunc(parsed)
63
+ })
64
+
65
+ // Calls stay raw records here: mapping is the normalizer's job, and `raw_snapshot`
66
+ // must keep every field Tillio sent, including operator-specific `extraFields`.
67
+ const getCallsResponseSchema = z.object({
68
+ calls: z.array(z.record(z.string(), z.unknown())).default([]),
69
+ pagination: z.object({
70
+ total: counterSchema,
71
+ page: counterSchema,
72
+ pages: counterSchema,
73
+ }),
74
+ })
75
+
76
+ export type TillioCallsPage = z.infer<typeof getCallsResponseSchema>
77
+
78
+ export type GetCallsParams = {
79
+ tenantDomain: string
80
+ token: string
81
+ from?: string
82
+ to?: string
83
+ page?: number
84
+ }
85
+
86
+ function sleep(ms: number): Promise<void> {
87
+ return new Promise((resolve) => setTimeout(resolve, ms))
88
+ }
89
+
90
+ function clampRetryAfterMs(header: string | null, fallback: number): number {
91
+ if (!header) return fallback
92
+ const seconds = Number(header)
93
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, TILLIO_RETRY_MAX_MS)
94
+ return fallback
95
+ }
96
+
97
+ function backoffMs(attempt: number): number {
98
+ const exponential = Math.min(TILLIO_RETRY_BASE_MS * 2 ** attempt, TILLIO_RETRY_MAX_MS)
99
+ const jitter = Math.floor(Math.random() * TILLIO_RETRY_BASE_MS)
100
+ return exponential + jitter
101
+ }
102
+
103
+ export function createTillioClient(environment: TillioClientEnvironment, deps: TillioClientDeps = {}) {
104
+ const apiUrl = environment.apiUrl.trim().replace(/\/+$/, '')
105
+ const apiKey = environment.apiKey.trim()
106
+ const tenantSystemId = environment.tenantSystemId.trim()
107
+ if (!apiUrl) throw new Error('Tillio environment is missing the API URL.')
108
+ if (!apiKey) throw new Error('Tillio environment is missing the API key.')
109
+ if (!tenantSystemId) throw new Error('Tillio environment is missing the tenant system id.')
110
+ // SSRF guard: apiUrl is user-supplied, so reject loopback/private/link-local targets
111
+ // before any request goes out (covers health check, validate, attach/detach, pull).
112
+ assertPublicTillioApiUrl(apiUrl)
113
+ const apiHostname = new URL(apiUrl).hostname.toLowerCase()
114
+
115
+ function buildHeaders(tenantDomain: string, token?: string): Record<string, string> {
116
+ const headers: Record<string, string> = {
117
+ 'X-Api-Key': apiKey,
118
+ 'X-System': tenantSystemId,
119
+ 'X-Tenant': tenantSystemId,
120
+ 'X-Tenant-Domain': tenantDomain,
121
+ }
122
+ if (token) headers['X-Token'] = token
123
+ return headers
124
+ }
125
+
126
+ async function throttle(): Promise<void> {
127
+ const wait = Math.max(0, TILLIO_MIN_REQUEST_SPACING_MS - (Date.now() - lastRequestAt))
128
+ if (wait > 0) await sleep(wait)
129
+ lastRequestAt = Date.now()
130
+ }
131
+
132
+ function resolveUrl(path: string, query?: Record<string, string>): string {
133
+ const url = new URL(path.replace(/^\/+/, ''), `${apiUrl}/`)
134
+ if (query) for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value)
135
+ return url.toString()
136
+ }
137
+
138
+ // `safeOutboundFetch` validates the URL, pins the connection to the address it validated
139
+ // (so DNS cannot rebind between check and connect) and never follows redirects on its own.
140
+ async function fetchOnce(
141
+ method: string,
142
+ url: string,
143
+ init: { headers: Record<string, string>; body?: unknown },
144
+ ): Promise<Response> {
145
+ const hasBody = init.body !== undefined
146
+ const controller = new AbortController()
147
+ const timer = setTimeout(
148
+ () => controller.abort(new FetchTimeoutError(redactTillioUrl(url), TILLIO_REQUEST_TIMEOUT_MS)),
149
+ TILLIO_REQUEST_TIMEOUT_MS,
150
+ )
151
+ try {
152
+ return await safeOutboundFetch(
153
+ url,
154
+ {
155
+ method,
156
+ headers: {
157
+ accept: 'application/json',
158
+ ...(hasBody ? { 'content-type': 'application/json' } : {}),
159
+ ...init.headers,
160
+ },
161
+ ...(hasBody ? { body: JSON.stringify(init.body) } : {}),
162
+ signal: controller.signal,
163
+ },
164
+ { subject: TILLIO_URL_SUBJECT, lookupHost: deps.lookupHost, fetchImpl: deps.fetchImpl },
165
+ )
166
+ } catch (err) {
167
+ if ((err as { name?: string } | null)?.name === 'AbortError') {
168
+ const reason = (controller.signal as AbortSignal & { reason?: unknown }).reason
169
+ if (reason instanceof Error) throw reason
170
+ }
171
+ throw err
172
+ } finally {
173
+ clearTimeout(timer)
174
+ }
175
+ }
176
+
177
+ // Every hop goes back through `fetchOnce`, so a redirect target is validated exactly like
178
+ // the original URL. The request carries `X-Api-Key` and `X-Token`, so a hop that leaves the
179
+ // configured host — or downgrades https to http — would hand those credentials to someone
180
+ // else and is refused instead of followed.
181
+ function resolveRedirectTarget(currentUrl: string, response: Response): string {
182
+ const location = response.headers.get('location')
183
+ if (!location) {
184
+ throw new TillioApiError(
185
+ `Tillio answered ${response.status} without a Location header`,
186
+ response.status,
187
+ 'redirect',
188
+ )
189
+ }
190
+ let target: URL
191
+ try {
192
+ target = new URL(location, currentUrl)
193
+ } catch {
194
+ throw new TillioApiError(`Tillio redirected to an unusable location`, response.status, 'redirect')
195
+ }
196
+ const current = new URL(currentUrl)
197
+ if (target.hostname.toLowerCase() !== apiHostname) {
198
+ throw new TillioApiError(
199
+ `Tillio redirected off the configured host, to "${target.hostname}"`,
200
+ response.status,
201
+ 'redirect',
202
+ )
203
+ }
204
+ if (current.protocol === 'https:' && target.protocol !== 'https:') {
205
+ throw new TillioApiError('Tillio redirected from https to a plaintext URL', response.status, 'redirect')
206
+ }
207
+ return target.toString()
208
+ }
209
+
210
+ async function rawRequest(
211
+ method: string,
212
+ url: string,
213
+ init: { headers: Record<string, string>; body?: unknown },
214
+ attempt = 0,
215
+ ): Promise<Response> {
216
+ await throttle()
217
+ let currentMethod = method
218
+ let currentUrl = url
219
+ let currentInit = init
220
+ let response: Response
221
+ for (let hop = 0; ; hop += 1) {
222
+ try {
223
+ response = await fetchOnce(currentMethod, currentUrl, currentInit)
224
+ } catch (err) {
225
+ if (err instanceof FetchTimeoutError) {
226
+ throw new TillioApiError(
227
+ `Tillio request timed out: ${currentMethod} ${redactTillioUrl(currentUrl)}`,
228
+ 0,
229
+ 'timeout',
230
+ )
231
+ }
232
+ if (err instanceof UnsafeOutboundUrlError) {
233
+ throw new TillioApiError(`Tillio request blocked: ${err.message}`, 0, err.reason)
234
+ }
235
+ const message = err instanceof Error ? err.message : 'network error'
236
+ throw new TillioApiError(
237
+ `Tillio request failed: ${currentMethod} ${redactTillioUrl(currentUrl)}: ${message}`,
238
+ 0,
239
+ 'network',
240
+ )
241
+ }
242
+
243
+ if (!REDIRECT_STATUSES.has(response.status)) break
244
+ if (hop >= TILLIO_MAX_REDIRECTS) {
245
+ throw new TillioApiError(
246
+ `Tillio redirected more than ${TILLIO_MAX_REDIRECTS} times`,
247
+ response.status,
248
+ 'redirect',
249
+ )
250
+ }
251
+ currentUrl = resolveRedirectTarget(currentUrl, response)
252
+ // 303 means "fetch the result with GET", so the body must not be replayed.
253
+ if (response.status === 303 && currentMethod !== 'GET' && currentMethod !== 'HEAD') {
254
+ currentMethod = 'GET'
255
+ currentInit = { headers: currentInit.headers }
256
+ }
257
+ }
258
+
259
+ if (RETRYABLE_STATUSES.has(response.status) && attempt < TILLIO_MAX_RETRIES) {
260
+ const delay = clampRetryAfterMs(response.headers.get('retry-after'), backoffMs(attempt))
261
+ await sleep(delay)
262
+ return rawRequest(method, url, init, attempt + 1)
263
+ }
264
+
265
+ return response
266
+ }
267
+
268
+ async function requestJson<T>(
269
+ method: string,
270
+ path: string,
271
+ options: { headers: Record<string, string>; body?: unknown; query?: Record<string, string>; schema: z.ZodType<T> },
272
+ ): Promise<T> {
273
+ const url = resolveUrl(path, options.query)
274
+ const response = await rawRequest(method, url, { headers: options.headers, body: options.body })
275
+ const text = await response.text()
276
+
277
+ let parsed: unknown
278
+ try {
279
+ parsed = text ? JSON.parse(text) : {}
280
+ } catch {
281
+ throw new TillioApiError(
282
+ response.ok ? 'Tillio returned a non-JSON response' : `Tillio request failed (${response.status})`,
283
+ response.status,
284
+ text.slice(0, 200),
285
+ )
286
+ }
287
+
288
+ const asError = errorResponseSchema.safeParse(parsed)
289
+ if (asError.success && asError.data.error) {
290
+ throw new TillioApiError(asError.data.message ?? asError.data.error, response.status, asError.data.error)
291
+ }
292
+
293
+ if (!response.ok) {
294
+ throw new TillioApiError(`Tillio request failed (${response.status})`, response.status, JSON.stringify(parsed).slice(0, 200))
295
+ }
296
+
297
+ return options.schema.parse(parsed)
298
+ }
299
+
300
+ return {
301
+ async getPlugins(tenantDomain: string): Promise<unknown> {
302
+ return requestJson('GET', '/api/plugins', { headers: buildHeaders(tenantDomain), schema: z.unknown() })
303
+ },
304
+
305
+ async getCalls(params: GetCallsParams): Promise<TillioCallsPage> {
306
+ const query: Record<string, string> = {}
307
+ if (params.from) query.from = params.from
308
+ if (params.to) query.to = params.to
309
+ if (params.page !== undefined) query.page = String(params.page)
310
+ return requestJson('GET', '/api/call', {
311
+ headers: buildHeaders(params.tenantDomain, params.token),
312
+ query,
313
+ schema: getCallsResponseSchema,
314
+ })
315
+ },
316
+
317
+ async validateConfig(plugin: TillioPlugin, config: Record<string, unknown>, tenantDomain: string): Promise<void> {
318
+ await requestJson('POST', '/api/config/validate', { headers: buildHeaders(tenantDomain), body: { plugin, config }, schema: z.unknown() })
319
+ },
320
+
321
+ async addConfig(plugin: TillioPlugin, config: Record<string, unknown>, tenantDomain: string): Promise<{ token: string }> {
322
+ return requestJson('POST', '/api/config', { headers: buildHeaders(tenantDomain), body: { plugin, config }, schema: addConfigResponseSchema })
323
+ },
324
+
325
+ async deleteConfig(plugin: TillioPlugin, token: string, tenantDomain: string): Promise<void> {
326
+ await requestJson('DELETE', '/api/config', { headers: buildHeaders(tenantDomain), query: { plugin, token }, schema: z.unknown() })
327
+ },
328
+ }
329
+ }
@@ -0,0 +1,53 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { z } from 'zod'
3
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
4
+ import { isValidTimeZone } from './tz'
5
+
6
+ // Own key, like the operators blob: saving integration credentials keeps the submitted fields
7
+ // plus the schema's secret fields, and the identity is in neither, so it was dropped on every save.
8
+ export const TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID = 'tillio_environment'
9
+
10
+ export const ENV_PROBE_TENANT_DOMAIN = 'test_connection'
11
+
12
+ export const environmentSchema = z.object({
13
+ apiUrl: z.string().trim().min(1),
14
+ apiKey: z.string().trim().min(1),
15
+ tenantSystemId: z.string().trim().optional(),
16
+ // Optional so an environment saved before this field existed still parses; readers fall back to
17
+ // TILLIO_TIMEZONE.
18
+ timeZone: z
19
+ .string()
20
+ .trim()
21
+ .refine(isValidTimeZone, { message: 'Expected an IANA time zone name' })
22
+ .optional(),
23
+ })
24
+
25
+ export type TillioEnvironment = z.infer<typeof environmentSchema>
26
+
27
+ const identitySchema = z.object({ tenantSystemId: z.string().trim().min(1) })
28
+
29
+ export type TillioIdentityStore = {
30
+ getRaw: (integrationId: string, scope: IntegrationScope) => Promise<Record<string, unknown> | null>
31
+ save: (integrationId: string, credentials: Record<string, unknown>, scope: IntegrationScope) => Promise<void>
32
+ }
33
+
34
+ export function generateTenantSystemId(): string {
35
+ return `OM-${randomBytes(6).toString('hex')}`
36
+ }
37
+
38
+ export async function readTenantSystemId(
39
+ credentialsService: TillioIdentityStore,
40
+ scope: IntegrationScope,
41
+ ): Promise<string | null> {
42
+ const raw = await credentialsService.getRaw(TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID, scope)
43
+ const parsed = identitySchema.safeParse(raw ?? {})
44
+ return parsed.success ? parsed.data.tenantSystemId : null
45
+ }
46
+
47
+ export async function saveTenantSystemId(
48
+ credentialsService: TillioIdentityStore,
49
+ scope: IntegrationScope,
50
+ tenantSystemId: string,
51
+ ): Promise<void> {
52
+ await credentialsService.save(TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID, { tenantSystemId }, scope)
53
+ }
@@ -0,0 +1,67 @@
1
+ import type { PullBlocker } from './pull-readiness'
2
+
3
+ // Routes answer with a stable code and the widgets own the wording, so a non-English user never
4
+ // sees a server string. `message` stays in the payload for API consumers and logs only.
5
+ export const TILLIO_ERROR_CODES = [
6
+ 'unauthorized',
7
+ 'invalid_payload',
8
+ 'invalid_operator_id',
9
+ 'integration_disabled',
10
+ 'environment_not_ready',
11
+ 'operator_missing',
12
+ 'environment_drift',
13
+ 'pull_already_running',
14
+ 'pull_failed',
15
+ 'app_url_missing',
16
+ 'operator_limit',
17
+ 'provider_error',
18
+ 'revocation_failed',
19
+ 'attach_failed',
20
+ 'detach_failed',
21
+ ] as const
22
+
23
+ export type TillioErrorCode = typeof TILLIO_ERROR_CODES[number]
24
+
25
+ type TillioErrorCopy = { key: string; fallback: string }
26
+
27
+ const COPY: Record<TillioErrorCode, TillioErrorCopy> = {
28
+ unauthorized: { key: 'tillio.errors.unauthorized', fallback: 'Your session expired. Sign in again.' },
29
+ invalid_payload: { key: 'tillio.errors.invalidPayload', fallback: 'The request was rejected as invalid.' },
30
+ invalid_operator_id: { key: 'tillio.errors.invalidOperatorId', fallback: 'That operator id is not valid.' },
31
+ integration_disabled: { key: 'tillio.errors.integrationDisabled', fallback: 'The Tillio integration is disabled. Enable it first.' },
32
+ environment_not_ready: { key: 'tillio.errors.environmentNotReady', fallback: 'Configure the Tillio credentials and run the health check first.' },
33
+ operator_missing: { key: 'tillio.errors.operatorMissing', fallback: 'Attach a Tillio operator first.' },
34
+ environment_drift: { key: 'tillio.errors.environmentDrift', fallback: 'The environment changed after the operator was attached. Detach and attach it again.' },
35
+ pull_already_running: { key: 'tillio.errors.pullAlreadyRunning', fallback: 'A pull is already running. Wait for it to finish.' },
36
+ pull_failed: { key: 'tillio.errors.pullFailed', fallback: 'Could not pull calls from Tillio.' },
37
+ app_url_missing: { key: 'tillio.errors.appUrlMissing', fallback: 'APP_URL is not configured, so the operator webhook domain cannot be derived.' },
38
+ operator_limit: { key: 'tillio.errors.operatorLimit', fallback: 'An operator is already attached. Detach it first.' },
39
+ provider_error: { key: 'tillio.errors.providerError', fallback: 'Tillio rejected the request.' },
40
+ revocation_failed: { key: 'tillio.errors.revocationFailed', fallback: 'The Tillio token could not be revoked.' },
41
+ attach_failed: { key: 'tillio.errors.attachFailed', fallback: 'Could not attach the operator.' },
42
+ detach_failed: { key: 'tillio.errors.detachFailed', fallback: 'Could not detach the operator.' },
43
+ }
44
+
45
+ export function tillioErrorCopy(code: string | null | undefined, fallbackCode: TillioErrorCode): TillioErrorCopy {
46
+ const known = TILLIO_ERROR_CODES.find((entry) => entry === code)
47
+ return COPY[known ?? fallbackCode]
48
+ }
49
+
50
+ // Keyed by the shared union so a blocker the server learns to report cannot reach the dialog
51
+ // unworded: adding one to `PullBlocker` fails this record until it gets copy of its own. The pull
52
+ // entries are worded for the action in progress, which the generic `COPY` above cannot be.
53
+ export const PULL_BLOCKER_COPY: Record<PullBlocker, TillioErrorCopy> = {
54
+ integration_disabled: COPY.integration_disabled,
55
+ environment_not_ready: {
56
+ key: 'tillio.pull.envNotReady',
57
+ fallback: 'Configure the Tillio credentials and run the health check on the integration page first.',
58
+ },
59
+ operator_missing: {
60
+ key: 'tillio.pull.operatorMissing',
61
+ fallback: 'Attach a Tillio operator on the integration page before pulling calls.',
62
+ },
63
+ environment_drift: {
64
+ key: 'tillio.pull.envDrift',
65
+ fallback: 'The Tillio environment changed after the operator was attached. Detach and attach it again before pulling calls.',
66
+ },
67
+ }
@@ -0,0 +1,11 @@
1
+ export class TillioApiError extends Error {
2
+ readonly status: number
3
+ readonly detail: string
4
+
5
+ constructor(message: string, status: number, detail: string) {
6
+ super(message)
7
+ this.name = 'TillioApiError'
8
+ this.status = status
9
+ this.detail = detail
10
+ }
11
+ }
@@ -0,0 +1,57 @@
1
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
2
+ import { TillioApiError } from './errors'
3
+ import { createTillioClient } from './client'
4
+ import {
5
+ ENV_PROBE_TENANT_DOMAIN,
6
+ environmentSchema,
7
+ generateTenantSystemId,
8
+ readTenantSystemId,
9
+ saveTenantSystemId,
10
+ type TillioIdentityStore,
11
+ } from './environment'
12
+
13
+ export type CredentialsServiceLike = TillioIdentityStore
14
+
15
+ type HealthResult = {
16
+ status: 'healthy' | 'degraded' | 'unhealthy'
17
+ message?: string
18
+ details?: Record<string, unknown>
19
+ }
20
+
21
+ export function createTillioEnvironmentHealthCheck(deps: { credentialsService: CredentialsServiceLike }) {
22
+ return {
23
+ async check(credentials: Record<string, unknown> | null, scope: IntegrationScope): Promise<HealthResult> {
24
+ const parsed = environmentSchema.safeParse(credentials ?? {})
25
+ if (!parsed.success) {
26
+ return { status: 'unhealthy', message: 'Environment is not configured (set the Tillio API URL and API key).' }
27
+ }
28
+
29
+ // Writes on first run: the client sends this id as X-System/X-Tenant and refuses to build
30
+ // without one, so the probe below cannot run before it exists. A subscriber on
31
+ // `integrations.credentials.updated` would leave a window where Check reports "not configured".
32
+ let tenantSystemId = await readTenantSystemId(deps.credentialsService, scope)
33
+ if (!tenantSystemId) {
34
+ // Adopts an identity still sitting in the legacy record before minting a new one:
35
+ // a fresh id would change X-System/X-Tenant and invalidate every operator token.
36
+ tenantSystemId = parsed.data.tenantSystemId ?? generateTenantSystemId()
37
+ try {
38
+ await saveTenantSystemId(deps.credentialsService, scope, tenantSystemId)
39
+ } catch (err) {
40
+ const message = err instanceof Error ? err.message : 'Failed to persist the environment identity'
41
+ return { status: 'unhealthy', message }
42
+ }
43
+ }
44
+
45
+ try {
46
+ const client = createTillioClient({ apiUrl: parsed.data.apiUrl, apiKey: parsed.data.apiKey, tenantSystemId })
47
+ // No dedicated ping endpoint, so listing plugins stands in for one: read-only and over the
48
+ // same auth path a real call takes. The probe domain is a throwaway — no operator exists yet.
49
+ await client.getPlugins(ENV_PROBE_TENANT_DOMAIN)
50
+ return { status: 'healthy', message: 'Tillio environment connected.' }
51
+ } catch (err) {
52
+ const message = err instanceof TillioApiError || err instanceof Error ? err.message : 'Environment check failed'
53
+ return { status: 'unhealthy', message }
54
+ }
55
+ },
56
+ }
57
+ }
@@ -0,0 +1,30 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
3
+
4
+ /**
5
+ * Runs `run` with a scope-wide lock held. Both call sites read state, talk to Tillio and then
6
+ * write, and neither store underneath them offers a compare-and-set, so re-checking before the
7
+ * write can only narrow the race, never close it.
8
+ *
9
+ * `pg_advisory_xact_lock` is the same primitive `attachments`, `notifications` and `sso` use, and
10
+ * it releases with the transaction, so a request that dies mid-section cannot leave it held. The
11
+ * transaction exists to own the lock: the work inside writes through its own services and is not
12
+ * made atomic by it.
13
+ */
14
+ export type TillioLockRunner = <T>(run: () => Promise<T>) => Promise<T>
15
+
16
+ export function createTillioLock(em: EntityManager, key: string): TillioLockRunner {
17
+ return async <T>(run: () => Promise<T>): Promise<T> =>
18
+ em.transactional(async (tx) => {
19
+ await tx.getConnection().execute('select pg_advisory_xact_lock(hashtext(?::text))', [key])
20
+ return run()
21
+ })
22
+ }
23
+
24
+ export function tillioOperatorLockKey(scope: IntegrationScope): string {
25
+ return `tillio:operators:${scope.tenantId}:${scope.organizationId}`
26
+ }
27
+
28
+ export function tillioPullLockKey(scope: IntegrationScope): string {
29
+ return `tillio:pull:${scope.tenantId}:${scope.organizationId}`
30
+ }