@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,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
+ '&quot;': '"',
38
+ '&apos;': "'",
39
+ '&#39;': "'",
40
+ '&lt;': '<',
41
+ '&gt;': '>',
42
+ '&amp;': '&',
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
+ }