@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,184 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type {
|
|
3
|
+
NormalizedPhoneCall,
|
|
4
|
+
NormalizedPhoneCallParticipant,
|
|
5
|
+
NormalizedPhoneCallRecording,
|
|
6
|
+
PhoneCallDirection,
|
|
7
|
+
PhoneCallStatus,
|
|
8
|
+
} from '@open-mercato/shared/modules/phone_calls/types'
|
|
9
|
+
import type { TillioOperatorPlugin } from './operators-store'
|
|
10
|
+
import { parseTillioTimestamp } from './tz'
|
|
11
|
+
|
|
12
|
+
const stringish = z.union([z.string(), z.number()]).transform((value) => String(value))
|
|
13
|
+
|
|
14
|
+
const tillioCallSchema = z.object({
|
|
15
|
+
id: stringish,
|
|
16
|
+
date: stringish.optional(),
|
|
17
|
+
type: stringish.optional(),
|
|
18
|
+
caller: stringish.optional(),
|
|
19
|
+
destination: stringish.optional(),
|
|
20
|
+
status: stringish.optional(),
|
|
21
|
+
waitTime: stringish.optional(),
|
|
22
|
+
billSec: stringish.optional(),
|
|
23
|
+
extraFields: z.record(z.string(), z.unknown()).optional(),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export type TillioRawCall = z.infer<typeof tillioCallSchema>
|
|
27
|
+
|
|
28
|
+
const ANSWERED_STATUSES = new Set(['ANSWERED', 'PROPER', 'REPEATED', 'CONNECTED'])
|
|
29
|
+
// The call reached the callee side but no conversation happened: nobody picked up,
|
|
30
|
+
// voicemail took it, there was nowhere to forward it, or the callee was on another
|
|
31
|
+
// call.
|
|
32
|
+
const MISSED_STATUSES = new Set(['NO ANSWER', 'NO FORWARD', 'VOICEMAIL', 'NO EXTENSION', 'BUSY'])
|
|
33
|
+
// The call could not be set up at all (SIP 603).
|
|
34
|
+
const FAILED_STATUSES = new Set(['FAILED'])
|
|
35
|
+
|
|
36
|
+
const HTML_ENTITIES: Record<string, string> = {
|
|
37
|
+
'"': '"',
|
|
38
|
+
''': "'",
|
|
39
|
+
''': "'",
|
|
40
|
+
'<': '<',
|
|
41
|
+
'>': '>',
|
|
42
|
+
'&': '&',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type TillioCallExtraction = {
|
|
46
|
+
callerDisplayName?: string | null
|
|
47
|
+
recording?: NormalizedPhoneCallRecording | null
|
|
48
|
+
providerFacts?: Record<string, unknown>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type TillioCallExtractor = (extraFields: Record<string, unknown>) => TillioCallExtraction
|
|
52
|
+
|
|
53
|
+
export type NormalizeContext = {
|
|
54
|
+
operatorId: string
|
|
55
|
+
plugin: TillioOperatorPlugin
|
|
56
|
+
timeZone?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function decodeHtmlEntities(value: string): string {
|
|
60
|
+
return value.replace(/&(?:quot|apos|#39|lt|gt|amp);/g, (entity) => HTML_ENTITIES[entity] ?? entity)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Ringostat sends a SIP-style `"display name" <uri>` string, HTML-escaped.
|
|
64
|
+
function extractDisplayName(value: string): string | null {
|
|
65
|
+
const decoded = decodeHtmlEntities(value).trim()
|
|
66
|
+
if (!decoded) return null
|
|
67
|
+
const uriStart = decoded.indexOf('<')
|
|
68
|
+
const beforeUri = (uriStart >= 0 ? decoded.slice(0, uriStart) : decoded).trim()
|
|
69
|
+
const name = beforeUri.startsWith('"') && beforeUri.endsWith('"')
|
|
70
|
+
? beforeUri.slice(1, -1).trim()
|
|
71
|
+
: beforeUri
|
|
72
|
+
return name || null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readString(source: Record<string, unknown>, key: string): string | null {
|
|
76
|
+
const value = source[key]
|
|
77
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
78
|
+
if (typeof value === 'number') return String(value)
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseSeconds(value: string | undefined): number | null {
|
|
83
|
+
if (value === undefined) return null
|
|
84
|
+
const parsed = Number(value)
|
|
85
|
+
if (!Number.isFinite(parsed) || parsed < 0) return null
|
|
86
|
+
return Math.trunc(parsed)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function mapDirection(type: string | undefined): PhoneCallDirection {
|
|
90
|
+
switch ((type ?? '').trim().toUpperCase()) {
|
|
91
|
+
case 'IN':
|
|
92
|
+
return 'inbound'
|
|
93
|
+
case 'OUT':
|
|
94
|
+
return 'outbound'
|
|
95
|
+
default:
|
|
96
|
+
return 'unknown'
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A billed call connected, whatever the textual status claims. A status Tillio has
|
|
101
|
+
// not documented yet stays 'unknown' rather than being reported as a failure.
|
|
102
|
+
export function mapStatus(status: string | undefined, durationSeconds: number | null): PhoneCallStatus {
|
|
103
|
+
if (durationSeconds !== null && durationSeconds > 0) return 'answered'
|
|
104
|
+
const normalized = (status ?? '').trim().toUpperCase()
|
|
105
|
+
if (ANSWERED_STATUSES.has(normalized)) return 'answered'
|
|
106
|
+
if (MISSED_STATUSES.has(normalized)) return 'missed'
|
|
107
|
+
if (FAILED_STATUSES.has(normalized)) return 'failed'
|
|
108
|
+
return 'unknown'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const ringostatExtractor: TillioCallExtractor = (extraFields) => {
|
|
112
|
+
const providerFacts: Record<string, unknown> = {}
|
|
113
|
+
const callCard = readString(extraFields, 'call_card')
|
|
114
|
+
if (callCard) providerFacts.tillioCallCardUrl = callCard
|
|
115
|
+
|
|
116
|
+
const recordingUrl = readString(extraFields, 'recording')
|
|
117
|
+
const hasRecording = readString(extraFields, 'has_recording') === '1' && Boolean(recordingUrl)
|
|
118
|
+
|
|
119
|
+
const rawCaller = readString(extraFields, 'caller')
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
callerDisplayName: rawCaller ? extractDisplayName(rawCaller) : null,
|
|
123
|
+
recording: hasRecording ? { url: recordingUrl } : null,
|
|
124
|
+
providerFacts,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const EXTRACTORS: Record<TillioOperatorPlugin, TillioCallExtractor> = {
|
|
129
|
+
Ringostat: ringostatExtractor,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getExtractorForPlugin(plugin: TillioOperatorPlugin): TillioCallExtractor {
|
|
133
|
+
return EXTRACTORS[plugin]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function normalizeTillioCall(
|
|
137
|
+
rawCall: Record<string, unknown>,
|
|
138
|
+
context: NormalizeContext,
|
|
139
|
+
): NormalizedPhoneCall {
|
|
140
|
+
const parsed = tillioCallSchema.safeParse(rawCall)
|
|
141
|
+
if (!parsed.success) {
|
|
142
|
+
throw new Error('[internal] Tillio returned a call payload without a usable id.')
|
|
143
|
+
}
|
|
144
|
+
const call = parsed.data
|
|
145
|
+
const extraction = getExtractorForPlugin(context.plugin)(call.extraFields ?? {})
|
|
146
|
+
|
|
147
|
+
const durationSeconds = parseSeconds(call.billSec)
|
|
148
|
+
const waitTimeSeconds = parseSeconds(call.waitTime)
|
|
149
|
+
|
|
150
|
+
const participants: NormalizedPhoneCallParticipant[] = []
|
|
151
|
+
if (call.caller) {
|
|
152
|
+
participants.push({
|
|
153
|
+
role: 'caller',
|
|
154
|
+
phoneNumber: call.caller,
|
|
155
|
+
displayName: extraction.callerDisplayName ?? null,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
if (call.destination) {
|
|
159
|
+
participants.push({ role: 'callee', phoneNumber: call.destination })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const providerFacts: Record<string, unknown> = {
|
|
163
|
+
...extraction.providerFacts,
|
|
164
|
+
tillioStatus: call.status ?? null,
|
|
165
|
+
tillioType: call.type ?? null,
|
|
166
|
+
operatorId: context.operatorId,
|
|
167
|
+
plugin: context.plugin,
|
|
168
|
+
}
|
|
169
|
+
if (waitTimeSeconds !== null) providerFacts.tillioWaitTimeSeconds = waitTimeSeconds
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
externalCallId: call.id,
|
|
173
|
+
direction: mapDirection(call.type),
|
|
174
|
+
status: mapStatus(call.status, durationSeconds),
|
|
175
|
+
participants,
|
|
176
|
+
recording: extraction.recording ?? null,
|
|
177
|
+
startedAt: parseTillioTimestamp(call.date, context.timeZone),
|
|
178
|
+
answeredAt: null,
|
|
179
|
+
endedAt: null,
|
|
180
|
+
durationSeconds,
|
|
181
|
+
providerFacts,
|
|
182
|
+
rawPayload: rawCall,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { scryptSync } from 'node:crypto'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
|
|
4
|
+
|
|
5
|
+
// Own key in the credentials store rather than a table: `integration_credentials.integration_id` is
|
|
6
|
+
// plain text with no registry lookup, so a provider key is accepted and the token inherits the
|
|
7
|
+
// store's encryption. Distinct from TILLIO_INTEGRATION_ID so detaching never touches the environment.
|
|
8
|
+
export const TILLIO_OPERATORS_INTEGRATION_ID = 'tillio_operators'
|
|
9
|
+
|
|
10
|
+
export const operatorPluginSchema = z.enum(['Ringostat'])
|
|
11
|
+
export type TillioOperatorPlugin = z.infer<typeof operatorPluginSchema>
|
|
12
|
+
|
|
13
|
+
export const ringostatConfigSchema = z.object({
|
|
14
|
+
key: z.string().trim().min(1),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export const operatorRecordSchema = z.object({
|
|
18
|
+
id: z.string().trim().min(1),
|
|
19
|
+
plugin: operatorPluginSchema,
|
|
20
|
+
label: z.string().trim().optional(),
|
|
21
|
+
config: z.record(z.string(), z.unknown()),
|
|
22
|
+
token: z.string().trim().min(1),
|
|
23
|
+
tenantDomain: z.string().trim().min(1),
|
|
24
|
+
envFingerprint: z.string().trim().min(1),
|
|
25
|
+
})
|
|
26
|
+
export type TillioOperatorRecord = z.infer<typeof operatorRecordSchema>
|
|
27
|
+
|
|
28
|
+
export const operatorsBlobSchema = z.object({
|
|
29
|
+
operators: z.array(operatorRecordSchema).default([]),
|
|
30
|
+
defaultOperatorId: z.string().trim().nullable().default(null),
|
|
31
|
+
})
|
|
32
|
+
export type TillioOperatorsBlob = z.infer<typeof operatorsBlobSchema>
|
|
33
|
+
|
|
34
|
+
const EMPTY_BLOB: TillioOperatorsBlob = { operators: [], defaultOperatorId: null }
|
|
35
|
+
|
|
36
|
+
export type TillioCredentialsService = {
|
|
37
|
+
getRaw: (integrationId: string, scope: IntegrationScope) => Promise<Record<string, unknown> | null>
|
|
38
|
+
save: (integrationId: string, credentials: Record<string, unknown>, scope: IntegrationScope) => Promise<void>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function computeEnvFingerprint(env: { tenantSystemId: string; apiUrl: string; apiKey: string }): string {
|
|
42
|
+
const material = `${env.tenantSystemId}\n${env.apiUrl}\n${env.apiKey}`
|
|
43
|
+
return scryptSync(material, 'open-mercato:tillio:environment-fingerprint:v1', 32).toString('hex')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveAppHost(appUrl: string): string {
|
|
47
|
+
const trimmed = appUrl.trim()
|
|
48
|
+
if (!trimmed) return ''
|
|
49
|
+
try {
|
|
50
|
+
return new URL(trimmed).host
|
|
51
|
+
} catch {
|
|
52
|
+
return trimmed.replace(/^\/+/, '').split('/')[0] ?? ''
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildTenantDomain(appUrl: string, tenantSystemId: string, operatorId: string): string {
|
|
57
|
+
const host = resolveAppHost(appUrl)
|
|
58
|
+
if (!host) throw new Error('[internal] APP_URL is not configured; cannot build the Tillio X-Tenant-Domain.')
|
|
59
|
+
return `${host}/${tenantSystemId}-${operatorId}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function readOperatorsBlob(
|
|
63
|
+
credentialsService: TillioCredentialsService,
|
|
64
|
+
scope: IntegrationScope,
|
|
65
|
+
): Promise<TillioOperatorsBlob> {
|
|
66
|
+
const raw = await credentialsService.getRaw(TILLIO_OPERATORS_INTEGRATION_ID, scope)
|
|
67
|
+
const parsed = operatorsBlobSchema.safeParse(raw ?? {})
|
|
68
|
+
return parsed.success ? parsed.data : EMPTY_BLOB
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function saveOperatorsBlob(
|
|
72
|
+
credentialsService: TillioCredentialsService,
|
|
73
|
+
scope: IntegrationScope,
|
|
74
|
+
blob: TillioOperatorsBlob,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
await credentialsService.save(TILLIO_OPERATORS_INTEGRATION_ID, blob, scope)
|
|
77
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
2
|
+
import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
|
|
3
|
+
import { TillioApiError } from './errors'
|
|
4
|
+
import { createTillioClient } from './client'
|
|
5
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
6
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
7
|
+
import { IntegrationState } from '@open-mercato/core/modules/integrations/data/entities'
|
|
8
|
+
import { environmentSchema, readTenantSystemId } from './environment'
|
|
9
|
+
import type { TillioLockRunner } from './locking'
|
|
10
|
+
import { TILLIO_TIMEZONE } from './tz'
|
|
11
|
+
import { TILLIO_INTEGRATION_ID } from '../integration'
|
|
12
|
+
import {
|
|
13
|
+
buildTenantDomain,
|
|
14
|
+
computeEnvFingerprint,
|
|
15
|
+
operatorPluginSchema,
|
|
16
|
+
readOperatorsBlob,
|
|
17
|
+
ringostatConfigSchema,
|
|
18
|
+
saveOperatorsBlob,
|
|
19
|
+
type TillioCredentialsService,
|
|
20
|
+
type TillioOperatorPlugin,
|
|
21
|
+
type TillioOperatorRecord,
|
|
22
|
+
} from './operators-store'
|
|
23
|
+
|
|
24
|
+
const logger = createLogger('tillio').child({ component: 'operators' })
|
|
25
|
+
|
|
26
|
+
export type TillioResolvedEnvironment = {
|
|
27
|
+
apiUrl: string
|
|
28
|
+
apiKey: string
|
|
29
|
+
tenantSystemId: string
|
|
30
|
+
timeZone: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const TILLIO_OPERATOR_RESOURCE_KIND = 'tillio.operator'
|
|
34
|
+
|
|
35
|
+
export type OperatorErrorSection = 'environment' | 'operator'
|
|
36
|
+
|
|
37
|
+
export class TillioEnvironmentNotReadyError extends Error {
|
|
38
|
+
constructor(message = 'The Tillio environment is not ready. Save credentials and run the health check first.') {
|
|
39
|
+
super(message)
|
|
40
|
+
this.name = 'TillioEnvironmentNotReadyError'
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class TillioOperatorLimitError extends Error {
|
|
45
|
+
constructor(message = 'An operator is already attached. Detach it before attaching another one.') {
|
|
46
|
+
super(message)
|
|
47
|
+
this.name = 'TillioOperatorLimitError'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Raised when the operator's token could not be revoked at Tillio. The local record stays in
|
|
53
|
+
* place, because dropping it would leave a live token nobody holds a handle to any more.
|
|
54
|
+
* `environmentMissing` separates the two outcomes for the caller: a provider failure is worth
|
|
55
|
+
* retrying, a missing environment is not — it needs the credentials fixed, or a forced detach.
|
|
56
|
+
*/
|
|
57
|
+
export class TillioRevocationFailedError extends Error {
|
|
58
|
+
readonly environmentMissing: boolean
|
|
59
|
+
|
|
60
|
+
constructor(environmentMissing: boolean, message?: string) {
|
|
61
|
+
super(
|
|
62
|
+
message
|
|
63
|
+
?? (environmentMissing
|
|
64
|
+
? 'The Tillio environment is no longer configured, so the operator token cannot be revoked.'
|
|
65
|
+
: 'Tillio did not confirm the operator token was revoked.'),
|
|
66
|
+
)
|
|
67
|
+
this.name = 'TillioRevocationFailedError'
|
|
68
|
+
this.environmentMissing = environmentMissing
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function classifyTillioError(err: unknown): OperatorErrorSection {
|
|
73
|
+
if (err instanceof TillioApiError) {
|
|
74
|
+
if (err.status === 0 || err.status === 401 || err.status === 403) return 'environment'
|
|
75
|
+
}
|
|
76
|
+
return 'operator'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function resolveEnvironment(
|
|
80
|
+
credentialsService: TillioCredentialsService,
|
|
81
|
+
scope: IntegrationScope,
|
|
82
|
+
): Promise<TillioResolvedEnvironment | null> {
|
|
83
|
+
const raw = await credentialsService.getRaw(TILLIO_INTEGRATION_ID, scope)
|
|
84
|
+
const parsed = environmentSchema.safeParse(raw ?? {})
|
|
85
|
+
if (!parsed.success) return null
|
|
86
|
+
// Falls back to the legacy location until the next health check moves the identity across.
|
|
87
|
+
const tenantSystemId = (await readTenantSystemId(credentialsService, scope)) ?? parsed.data.tenantSystemId
|
|
88
|
+
if (!tenantSystemId) return null
|
|
89
|
+
return {
|
|
90
|
+
apiUrl: parsed.data.apiUrl,
|
|
91
|
+
apiKey: parsed.data.apiKey,
|
|
92
|
+
tenantSystemId,
|
|
93
|
+
timeZone: parsed.data.timeZone ?? TILLIO_TIMEZONE,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type TillioIntegrationState = {
|
|
98
|
+
/** The administrator's switch. Provisioning and sweeps stay off while it is false. */
|
|
99
|
+
enabled: boolean
|
|
100
|
+
/** The last probe result. Says Tillio answers, not that anybody wants us talking to it. */
|
|
101
|
+
healthy: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Both signals come off one row, and they fail for different reasons a user fixes in different
|
|
105
|
+
// places, so callers get them apart rather than folded into a single boolean. A missing row
|
|
106
|
+
// reads as disabled, matching `integrationStateService.resolveState`, which falls back to the
|
|
107
|
+
// descriptor's `defaultState` and to `false` without one. The Tillio descriptor declares no
|
|
108
|
+
// `defaultState`, so adding one there means switching this over to that service.
|
|
109
|
+
export async function readTillioIntegrationState(
|
|
110
|
+
em: EntityManager,
|
|
111
|
+
scope: IntegrationScope,
|
|
112
|
+
): Promise<TillioIntegrationState> {
|
|
113
|
+
const state = await findOneWithDecryption(
|
|
114
|
+
em,
|
|
115
|
+
IntegrationState,
|
|
116
|
+
{
|
|
117
|
+
integrationId: TILLIO_INTEGRATION_ID,
|
|
118
|
+
organizationId: scope.organizationId,
|
|
119
|
+
tenantId: scope.tenantId,
|
|
120
|
+
deletedAt: null,
|
|
121
|
+
},
|
|
122
|
+
undefined,
|
|
123
|
+
{ tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
124
|
+
)
|
|
125
|
+
return {
|
|
126
|
+
enabled: state?.isEnabled ?? false,
|
|
127
|
+
healthy: state?.lastHealthStatus === 'healthy',
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function operatorIdForPlugin(plugin: TillioOperatorPlugin): string {
|
|
132
|
+
return `${plugin.toLowerCase()}-1`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizeOperatorConfig(plugin: TillioOperatorPlugin, config: Record<string, unknown>): Record<string, unknown> {
|
|
136
|
+
if (plugin === 'Ringostat') return ringostatConfigSchema.parse(config)
|
|
137
|
+
return config
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type AttachOperatorDeps = {
|
|
141
|
+
credentialsService: TillioCredentialsService
|
|
142
|
+
scope: IntegrationScope
|
|
143
|
+
appUrl: string
|
|
144
|
+
/**
|
|
145
|
+
* Serializes the slot check and the write. Required rather than optional so a new call site
|
|
146
|
+
* has to decide: passing `(run) => run()` says the caller knows it cannot contend.
|
|
147
|
+
*/
|
|
148
|
+
withLock: TillioLockRunner
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type AttachOperatorInput = {
|
|
152
|
+
plugin: TillioOperatorPlugin
|
|
153
|
+
config: Record<string, unknown>
|
|
154
|
+
label?: string
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function attachOperator(
|
|
158
|
+
deps: AttachOperatorDeps,
|
|
159
|
+
input: AttachOperatorInput,
|
|
160
|
+
): Promise<TillioOperatorRecord> {
|
|
161
|
+
const plugin = operatorPluginSchema.parse(input.plugin)
|
|
162
|
+
const config = normalizeOperatorConfig(plugin, input.config)
|
|
163
|
+
|
|
164
|
+
const environment = await resolveEnvironment(deps.credentialsService, deps.scope)
|
|
165
|
+
if (!environment) throw new TillioEnvironmentNotReadyError()
|
|
166
|
+
|
|
167
|
+
const blob = await readOperatorsBlob(deps.credentialsService, deps.scope)
|
|
168
|
+
if (blob.operators.length > 0) throw new TillioOperatorLimitError()
|
|
169
|
+
|
|
170
|
+
const operatorId = operatorIdForPlugin(plugin)
|
|
171
|
+
const tenantDomain = buildTenantDomain(deps.appUrl, environment.tenantSystemId, operatorId)
|
|
172
|
+
const client = createTillioClient(environment)
|
|
173
|
+
|
|
174
|
+
await client.validateConfig(plugin, config, tenantDomain)
|
|
175
|
+
const { token } = await client.addConfig(plugin, config, tenantDomain)
|
|
176
|
+
|
|
177
|
+
const record: TillioOperatorRecord = {
|
|
178
|
+
id: operatorId,
|
|
179
|
+
plugin,
|
|
180
|
+
...(input.label ? { label: input.label } : {}),
|
|
181
|
+
config,
|
|
182
|
+
token,
|
|
183
|
+
tenantDomain,
|
|
184
|
+
// Pins the operator to the environment that issued its token. If the environment is later
|
|
185
|
+
// edited the pairing is stale, and comparing this fingerprint is what raises `environment_drift`
|
|
186
|
+
// instead of pulling against an instance the operator never belonged to.
|
|
187
|
+
envFingerprint: computeEnvFingerprint(environment),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
// The remote calls above stay outside the lock: holding a database transaction while
|
|
192
|
+
// waiting on Tillio would tie a connection to their latency. Inside it, the slot is
|
|
193
|
+
// re-checked and written as one step, so a concurrent attach either loses here and is
|
|
194
|
+
// undone below, or wins and is the only stored operator.
|
|
195
|
+
await deps.withLock(async () => {
|
|
196
|
+
const current = await readOperatorsBlob(deps.credentialsService, deps.scope)
|
|
197
|
+
if (current.operators.length > 0) throw new TillioOperatorLimitError()
|
|
198
|
+
|
|
199
|
+
await saveOperatorsBlob(deps.credentialsService, deps.scope, {
|
|
200
|
+
operators: [record],
|
|
201
|
+
defaultOperatorId: operatorId,
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
} catch (err) {
|
|
205
|
+
// `addConfig` already registered this operator on Tillio's side. If we cannot persist it
|
|
206
|
+
// locally we would lose the only reference to that remote config, so undo it before failing.
|
|
207
|
+
await client.deleteConfig(plugin, token, tenantDomain).catch((revokeErr: unknown) => {
|
|
208
|
+
logger.error('could not roll back the remote operator config', { operatorId, err: revokeErr })
|
|
209
|
+
})
|
|
210
|
+
throw err
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return record
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export type DetachOperatorDeps = {
|
|
217
|
+
credentialsService: TillioCredentialsService
|
|
218
|
+
scope: IntegrationScope
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export type DetachOperatorOptions = {
|
|
222
|
+
/**
|
|
223
|
+
* Drop the local record even though the token could not be revoked. The caller has to
|
|
224
|
+
* decide this explicitly, because the token stays live on Tillio's side afterwards.
|
|
225
|
+
*/
|
|
226
|
+
force?: boolean
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function detachOperator(
|
|
230
|
+
deps: DetachOperatorDeps,
|
|
231
|
+
operatorId: string,
|
|
232
|
+
options: DetachOperatorOptions = {},
|
|
233
|
+
): Promise<{ ok: boolean; detached: boolean; revoked: boolean }> {
|
|
234
|
+
const blob = await readOperatorsBlob(deps.credentialsService, deps.scope)
|
|
235
|
+
const operator = blob.operators.find((entry) => entry.id === operatorId)
|
|
236
|
+
if (!operator) return { ok: true, detached: false, revoked: false }
|
|
237
|
+
|
|
238
|
+
const environment = await resolveEnvironment(deps.credentialsService, deps.scope)
|
|
239
|
+
let revoked = false
|
|
240
|
+
if (!environment) {
|
|
241
|
+
if (!options.force) throw new TillioRevocationFailedError(true)
|
|
242
|
+
} else {
|
|
243
|
+
try {
|
|
244
|
+
const client = createTillioClient(environment)
|
|
245
|
+
await client.deleteConfig(operator.plugin, operator.token, operator.tenantDomain)
|
|
246
|
+
revoked = true
|
|
247
|
+
} catch (err) {
|
|
248
|
+
logger.error('could not revoke the Tillio operator token', {
|
|
249
|
+
operatorId,
|
|
250
|
+
status: err instanceof TillioApiError ? err.status : undefined,
|
|
251
|
+
detail: err instanceof TillioApiError ? err.detail : undefined,
|
|
252
|
+
err,
|
|
253
|
+
})
|
|
254
|
+
if (!options.force) {
|
|
255
|
+
throw new TillioRevocationFailedError(false)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const remaining = blob.operators.filter((entry) => entry.id !== operatorId)
|
|
261
|
+
await saveOperatorsBlob(deps.credentialsService, deps.scope, {
|
|
262
|
+
operators: remaining,
|
|
263
|
+
defaultOperatorId: blob.defaultOperatorId === operatorId
|
|
264
|
+
? (remaining[0]?.id ?? null)
|
|
265
|
+
: blob.defaultOperatorId,
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
return { ok: true, detached: true, revoked }
|
|
269
|
+
}
|