@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,33 @@
1
+ import { createModuleQueue, type Queue } from '@open-mercato/queue'
2
+ import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
3
+
4
+ /**
5
+ * Queue helper for the Tillio provider. Mirrors `getSyncQueue` (data_sync) so the
6
+ * route and the worker share one queue instance per name.
7
+ *
8
+ * Concurrency stays at 1 by default: a pull sweeps Tillio page by page with the
9
+ * operator's token, and running several sweeps at once only buys provider
10
+ * throttling. `TILLIO_QUEUE_CONCURRENCY` raises it for deployments that pull for
11
+ * many organizations at once, capped at the platform ceiling of 20.
12
+ */
13
+ const queues = new Map<string, Queue<Record<string, unknown>>>()
14
+
15
+ export const TILLIO_PULL_QUEUE = 'tillio-pull'
16
+
17
+ // Worker parallelism comes from the worker manifest, not from the producer-side queue
18
+ // instance this module builds, so both read the same value or the variable only pretends
19
+ // to do something.
20
+ export function resolveTillioQueueConcurrency(): number {
21
+ return Math.min(20, parseNumberWithDefault(process.env.TILLIO_QUEUE_CONCURRENCY, 1, { min: 1, integer: true }))
22
+ }
23
+
24
+ export function getTillioQueue(queueName: string): Queue<Record<string, unknown>> {
25
+ const existing = queues.get(queueName)
26
+ if (existing) return existing
27
+
28
+ const created = createModuleQueue<Record<string, unknown>>(queueName, {
29
+ concurrency: resolveTillioQueueConcurrency(),
30
+ })
31
+ queues.set(queueName, created)
32
+ return created
33
+ }
@@ -0,0 +1,121 @@
1
+ // The zone Tillio reports its wall-clock timestamps in. Overridable per environment, because an
2
+ // instance serving another market reports that market's local time.
3
+ export const TILLIO_TIMEZONE = 'Europe/Warsaw'
4
+
5
+ export function isValidTimeZone(value: string): boolean {
6
+ try {
7
+ new Intl.DateTimeFormat('en-US', { timeZone: value })
8
+ return true
9
+ } catch {
10
+ return false
11
+ }
12
+ }
13
+
14
+ const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
15
+
16
+ type WallClock = {
17
+ year: number
18
+ month: number
19
+ day: number
20
+ hour: number
21
+ minute: number
22
+ second: number
23
+ }
24
+
25
+ function readWallClock(date: Date, timeZone: string): WallClock {
26
+ const parts = new Intl.DateTimeFormat('en-US', {
27
+ timeZone,
28
+ hourCycle: 'h23',
29
+ year: 'numeric',
30
+ month: '2-digit',
31
+ day: '2-digit',
32
+ hour: '2-digit',
33
+ minute: '2-digit',
34
+ second: '2-digit',
35
+ }).formatToParts(date)
36
+ const read = (type: Intl.DateTimeFormatPartTypes): number =>
37
+ Number(parts.find((part) => part.type === type)?.value)
38
+ return {
39
+ year: read('year'),
40
+ month: read('month'),
41
+ day: read('day'),
42
+ hour: read('hour'),
43
+ minute: read('minute'),
44
+ second: read('second'),
45
+ }
46
+ }
47
+
48
+ function zoneOffsetMs(date: Date, timeZone: string): number {
49
+ const wall = readWallClock(date, timeZone)
50
+ const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second)
51
+ return asUtc - (date.getTime() - date.getMilliseconds())
52
+ }
53
+
54
+ // Two passes: the first offset is read at the naive instant, which lands in the
55
+ // wrong side of a DST transition for wall-clock times near the switch. Reading
56
+ // the offset again at the corrected instant settles it.
57
+ function zonedWallClockToUtc(
58
+ day: string,
59
+ hour: number,
60
+ minute: number,
61
+ second: number,
62
+ timeZone: string,
63
+ ): Date {
64
+ if (!DAY_PATTERN.test(day)) {
65
+ throw new Error(`[internal] Expected a YYYY-MM-DD day, received "${day}".`)
66
+ }
67
+ const [year, month, dayOfMonth] = day.split('-').map(Number)
68
+ const naive = Date.UTC(year, month - 1, dayOfMonth, hour, minute, second)
69
+ const firstPass = naive - zoneOffsetMs(new Date(naive), timeZone)
70
+ return new Date(naive - zoneOffsetMs(new Date(firstPass), timeZone))
71
+ }
72
+
73
+ export function zonedDayStart(day: string, timeZone: string = TILLIO_TIMEZONE): Date {
74
+ return zonedWallClockToUtc(day, 0, 0, 0, timeZone)
75
+ }
76
+
77
+ // 23:59, not 23:59:59: Tillio's date filter is `YYYY-MM-DD HH:mm` with no seconds field (see
78
+ // `formatTillioTimestamp`), so a finer bound cannot survive the trip out. Their own API
79
+ // samples use 23:59 as the end of a day for the same reason.
80
+ export function zonedDayEnd(day: string, timeZone: string = TILLIO_TIMEZONE): Date {
81
+ return zonedWallClockToUtc(day, 23, 59, 0, timeZone)
82
+ }
83
+
84
+ export function formatTillioTimestamp(date: Date, timeZone: string = TILLIO_TIMEZONE): string {
85
+ const wall = readWallClock(date, timeZone)
86
+ const pad = (value: number): string => String(value).padStart(2, '0')
87
+ return `${wall.year}-${pad(wall.month)}-${pad(wall.day)} ${pad(wall.hour)}:${pad(wall.minute)}`
88
+ }
89
+
90
+ const TZ_SUFFIX_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/i
91
+ const WALL_CLOCK_PATTERN = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/
92
+
93
+ // Tillio returns `date` in two shapes depending on the operator:
94
+ // - Ringostat: ISO with an explicit offset ("2026-04-11T12:47:28+0200") -> new Date is unambiguous.
95
+ // - Play: a Europe/Warsaw wall-clock time with no zone ("2026-05-09 08:49:39"). `new Date` would
96
+ // read it in the process-local zone, which is wrong on any server that is not Warsaw, so we
97
+ // resolve it explicitly through the Tillio timezone instead.
98
+ export function parseTillioTimestamp(
99
+ value: string | null | undefined,
100
+ timeZone: string = TILLIO_TIMEZONE,
101
+ ): Date | null {
102
+ if (!value) return null
103
+ const trimmed = value.trim()
104
+ if (!trimmed) return null
105
+ if (TZ_SUFFIX_PATTERN.test(trimmed)) {
106
+ const parsed = new Date(trimmed)
107
+ return Number.isNaN(parsed.getTime()) ? null : parsed
108
+ }
109
+ const match = WALL_CLOCK_PATTERN.exec(trimmed)
110
+ if (match) {
111
+ const [, day, hour, minute, second] = match
112
+ const [year, month, dayOfMonth] = day.split('-').map(Number)
113
+ // Tillio can emit a zeroed/blank stamp ("0000-00-00 00:00:00") for a missing date; it slips
114
+ // past the regex but is not a real calendar day, so treat it as "no timestamp" (matching what
115
+ // `new Date` used to yield here: Invalid Date -> null).
116
+ if (year < 1971 || month < 1 || month > 12 || dayOfMonth < 1 || dayOfMonth > 31) return null
117
+ return zonedWallClockToUtc(day, Number(hour), Number(minute), second ? Number(second) : 0, timeZone)
118
+ }
119
+ const parsed = new Date(trimmed)
120
+ return Number.isNaN(parsed.getTime()) ? null : parsed
121
+ }
@@ -0,0 +1,34 @@
1
+ // SSRF guard for the user-supplied Tillio API URL. `apiUrl` comes from integration
2
+ // credentials, so before createTillioClient ever fetches it we reject non-http(s) schemes
3
+ // and hosts that are loopback / private / link-local (cloud metadata endpoints, internal
4
+ // services). This is the synchronous, construction-time check; the resolution-time half
5
+ // (DNS answers, rebinding, redirect targets) is `safeOutboundFetch` in `client.ts`.
6
+
7
+ import { assertStaticallySafeOutboundUrl } from '@open-mercato/shared/lib/url-safety'
8
+
9
+ export const TILLIO_URL_SUBJECT = 'Tillio API URL'
10
+
11
+ export function assertPublicTillioApiUrl(rawUrl: string): void {
12
+ // The shared guard decides where a request may go, not whether what it carries stays
13
+ // confidential, so it admits plaintext http. Every Tillio request sends `X-Api-Key` and,
14
+ // once an operator is attached, `X-Token`, so http is refused here before any of them
15
+ // reaches the wire. No development allowance: `apiUrl` always points at Tillio's public
16
+ // host, and tests stub the transport through `fetchImpl` instead of relaxing this.
17
+ assertTillioTls(rawUrl)
18
+ assertStaticallySafeOutboundUrl(rawUrl, {
19
+ subject: TILLIO_URL_SUBJECT,
20
+ errorFactory: (_reason, message) => new Error(`${message}.`),
21
+ })
22
+ }
23
+
24
+ function assertTillioTls(rawUrl: string): void {
25
+ let protocol: string
26
+ try {
27
+ protocol = new URL(rawUrl).protocol
28
+ } catch {
29
+ // Malformed input belongs to the shared guard so one parser owns that message.
30
+ return
31
+ }
32
+ if (protocol !== 'http:') return
33
+ throw new Error(`${TILLIO_URL_SUBJECT} must use https, so the API key is not sent in cleartext.`)
34
+ }
@@ -0,0 +1,49 @@
1
+ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
2
+ import { createLogger } from '@open-mercato/shared/lib/logger'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { applyTillioEnvPreset } from './lib/preset'
5
+ import { createTillioLock, tillioOperatorLockKey } from './lib/locking'
6
+ import type { TillioCredentialsService } from './lib/operators-store'
7
+
8
+ const logger = createLogger('tillio').child({ component: 'setup' })
9
+
10
+ export const setup: ModuleSetupConfig = {
11
+ defaultRoleFeatures: {
12
+ admin: [
13
+ 'tillio.*',
14
+ ],
15
+ },
16
+
17
+ async seedDefaults({ tenantId, organizationId, container }) {
18
+ try {
19
+ const result = await applyTillioEnvPreset({
20
+ credentialsService: container.resolve('integrationCredentialsService') as TillioCredentialsService,
21
+ integrationStateService: container.resolve('integrationStateService'),
22
+ integrationHealthService: container.resolve('integrationHealthService'),
23
+ integrationLogService: container.resolve('integrationLogService'),
24
+ scope: { tenantId, organizationId },
25
+ withLock: createTillioLock(
26
+ container.resolve('em') as EntityManager,
27
+ tillioOperatorLockKey({ tenantId, organizationId }),
28
+ ),
29
+ })
30
+ if (result.status === 'skipped') {
31
+ logger.debug('env preset not applied', { reason: result.reason })
32
+ } else if (result.status === 'blocked') {
33
+ logger.warn('env preset refused', { reason: result.reason })
34
+ } else {
35
+ logger.info('env preset applied', {
36
+ credentialsAction: result.credentialsAction,
37
+ health: result.health,
38
+ operator: result.operator,
39
+ })
40
+ }
41
+ } catch (err) {
42
+ // A provider preset is never worth failing tenant bootstrap over; the operator can rerun
43
+ // `mercato tillio configure-from-env` once the deployment is fixed.
44
+ logger.warn('failed to apply the env preset during tenant setup', { err })
45
+ }
46
+ },
47
+ }
48
+
49
+ export default setup
@@ -0,0 +1,304 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
8
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
9
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
10
+ import { tillioErrorCopy } from '../../../lib/error-codes'
11
+ import type { EnvironmentBlocker } from '../../../lib/pull-readiness'
12
+ import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
13
+ import { StatusBadge } from '@open-mercato/ui/primitives/status-badge'
14
+ import { Button } from '@open-mercato/ui/primitives/button'
15
+ import { FormField } from '@open-mercato/ui/primitives/form-field'
16
+ import { Input } from '@open-mercato/ui/primitives/input'
17
+ import { Label } from '@open-mercato/ui/primitives/label'
18
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
19
+
20
+ type OperatorSummary = {
21
+ id: string
22
+ plugin: string
23
+ tenantDomain: string
24
+ stale: boolean
25
+ }
26
+
27
+ type OperatorsResponse = {
28
+ ok: boolean
29
+ environmentReady: boolean
30
+ environmentBlocker: EnvironmentBlocker | null
31
+ tenantSystemId: string | null
32
+ supportedPlugins: string[]
33
+ operators: OperatorSummary[]
34
+ defaultOperatorId: string | null
35
+ envDrift: boolean
36
+ }
37
+
38
+ type AttachResult = {
39
+ ok: boolean
40
+ code?: string
41
+ section?: 'environment' | 'operator'
42
+ operator?: { id: string; plugin: string; tenantDomain: string }
43
+ }
44
+
45
+ type DetachResult = {
46
+ ok: boolean
47
+ code?: string
48
+ section?: 'environment' | 'operator'
49
+ detached?: boolean
50
+ revoked?: boolean
51
+ /** Set when the record survived because the token could not be revoked. */
52
+ canForce?: boolean
53
+ }
54
+
55
+ export default function OperatorsConfigWidget(
56
+ _props: InjectionWidgetComponentProps<Record<string, unknown>, Record<string, unknown>>,
57
+ ) {
58
+ const t = useT()
59
+ const attachErrorText = React.useCallback((code: string | undefined) => {
60
+ const copy = tillioErrorCopy(code, 'attach_failed')
61
+ return t(copy.key, copy.fallback)
62
+ }, [t])
63
+ const detachErrorText = React.useCallback((code: string | undefined) => {
64
+ const copy = tillioErrorCopy(code, 'detach_failed')
65
+ return t(copy.key, copy.fallback)
66
+ }, [t])
67
+ const [state, setState] = React.useState<OperatorsResponse | null>(null)
68
+ const [loading, setLoading] = React.useState(true)
69
+ const [keyInput, setKeyInput] = React.useState('')
70
+ const [pending, setPending] = React.useState(false)
71
+ const { runMutation, retryLastMutation } = useGuardedMutation({
72
+ contextId: 'tillio-operators',
73
+ blockedMessage: t('tillio.operators.blocked', 'Operator change blocked by validation.'),
74
+ })
75
+ const mutationContext = React.useMemo(
76
+ () => ({ providerKey: 'tillio', retryLastMutation }),
77
+ [retryLastMutation],
78
+ )
79
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
80
+
81
+ const load = React.useCallback(async () => {
82
+ setLoading(true)
83
+ try {
84
+ const response = await apiCall<OperatorsResponse>('/api/tillio/operators')
85
+ if (response.ok && response.result) {
86
+ setState(response.result)
87
+ } else {
88
+ setState(null)
89
+ flash(t('tillio.operators.loadFailed', 'Failed to load Tillio operators.'), 'error')
90
+ }
91
+ } finally {
92
+ setLoading(false)
93
+ }
94
+ }, [t])
95
+
96
+ React.useEffect(() => {
97
+ void load()
98
+ }, [load])
99
+
100
+ const attach = React.useCallback(async () => {
101
+ if (pending) return
102
+ const key = keyInput.trim()
103
+ if (!key) {
104
+ flash(t('tillio.operators.keyRequired', 'Enter the Ringostat key first.'), 'error')
105
+ return
106
+ }
107
+ setPending(true)
108
+ try {
109
+ const response = await runMutation({
110
+ context: mutationContext,
111
+ mutationPayload: { providerKey: 'tillio', plugin: 'Ringostat' },
112
+ operation: () =>
113
+ apiCall<AttachResult>('/api/tillio/operators', {
114
+ method: 'POST',
115
+ headers: { 'content-type': 'application/json' },
116
+ body: JSON.stringify({ plugin: 'Ringostat', config: { key } }),
117
+ }),
118
+ })
119
+ const body = response.result as AttachResult | undefined
120
+ if (!response.ok) {
121
+ flash(attachErrorText(body?.code), 'error')
122
+ return
123
+ }
124
+ flash(t('tillio.operators.attached', 'Operator attached.'), 'success')
125
+ setKeyInput('')
126
+ await load()
127
+ } finally {
128
+ setPending(false)
129
+ }
130
+ }, [attachErrorText, keyInput, load, mutationContext, pending, runMutation, t])
131
+
132
+ const sendDetach = React.useCallback(async (operatorId: string, force: boolean) => {
133
+ const query = force ? '?force=true' : ''
134
+ return runMutation({
135
+ context: mutationContext,
136
+ mutationPayload: { providerKey: 'tillio', force },
137
+ operation: () =>
138
+ apiCall<DetachResult>(`/api/tillio/operators/${encodeURIComponent(operatorId)}${query}`, { method: 'DELETE' }),
139
+ })
140
+ }, [mutationContext, runMutation])
141
+
142
+ const detach = React.useCallback(async (operatorId: string) => {
143
+ const confirmed = await confirm({
144
+ title: t('tillio.operators.detachConfirmTitle', 'Detach this operator?'),
145
+ text: t('tillio.operators.detachConfirmText', 'The operator will be removed and its Tillio token revoked. You can attach it again afterwards.'),
146
+ confirmText: t('tillio.operators.detachConfirmAction', 'Detach'),
147
+ cancelText: t('tillio.operators.cancel', 'Cancel'),
148
+ variant: 'destructive',
149
+ })
150
+ if (!confirmed) return
151
+ setPending(true)
152
+ try {
153
+ const response = await sendDetach(operatorId, false)
154
+ const body = response.result as DetachResult | undefined
155
+ if (response.ok) {
156
+ flash(t('tillio.operators.detached', 'Operator detached.'), 'success')
157
+ await load()
158
+ return
159
+ }
160
+
161
+ // The token is still live on Tillio's side, so the record stayed. Removing it anyway
162
+ // is a separate, explicit decision — it leaves that token behind with no handle to it.
163
+ if (!body?.canForce) {
164
+ flash(detachErrorText(body?.code), 'error')
165
+ return
166
+ }
167
+ const forceConfirmed = await confirm({
168
+ title: t('tillio.operators.forceDetachTitle', 'Detach without revoking?'),
169
+ text: t('tillio.operators.forceDetachText', 'The Tillio token could not be revoked, so it stays active until you remove it in Tillio. Remove the operator here anyway?'),
170
+ confirmText: t('tillio.operators.forceDetachAction', 'Detach anyway'),
171
+ cancelText: t('tillio.operators.cancel', 'Cancel'),
172
+ variant: 'destructive',
173
+ })
174
+ if (!forceConfirmed) return
175
+
176
+ const forced = await sendDetach(operatorId, true)
177
+ if (!forced.ok) {
178
+ flash(t('tillio.operators.detachFailed', 'Could not detach the operator.'), 'error')
179
+ return
180
+ }
181
+ flash(t('tillio.operators.detachedNotRevoked', 'Operator removed. Revoke its token in Tillio manually.'), 'success')
182
+ await load()
183
+ } finally {
184
+ setPending(false)
185
+ }
186
+ }, [confirm, detachErrorText, load, sendDetach, t])
187
+
188
+ const copyTenantDomain = React.useCallback(async (tenantDomain: string) => {
189
+ try {
190
+ await navigator.clipboard.writeText(tenantDomain)
191
+ flash(t('tillio.operators.copied', 'Webhook domain copied.'), 'success')
192
+ } catch {
193
+ flash(t('tillio.operators.copyFailed', 'Could not copy to clipboard.'), 'error')
194
+ }
195
+ }, [t])
196
+
197
+ if (loading) {
198
+ return (
199
+ <div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
200
+ <Spinner />
201
+ {t('tillio.operators.loading', 'Loading operators...')}
202
+ </div>
203
+ )
204
+ }
205
+
206
+ if (!state) {
207
+ return (
208
+ <Alert status="error">
209
+ <AlertTitle>{t('tillio.operators.loadFailed', 'Failed to load Tillio operators.')}</AlertTitle>
210
+ <AlertDescription>
211
+ <Button type="button" variant="outline" size="sm" onClick={() => void load()}>
212
+ {t('tillio.operators.retry', 'Retry')}
213
+ </Button>
214
+ </AlertDescription>
215
+ </Alert>
216
+ )
217
+ }
218
+
219
+ const operator = state.operators[0] ?? null
220
+
221
+ return (
222
+ <div className="grid gap-4">
223
+ {ConfirmDialogElement}
224
+
225
+ {!state.environmentReady ? (
226
+ <Alert status="warning">
227
+ <AlertTitle>
228
+ {state.environmentBlocker === 'integration_disabled'
229
+ ? t('tillio.operators.integrationDisabledTitle', 'Integration disabled')
230
+ : t('tillio.operators.envNotReadyTitle', 'Environment not ready')}
231
+ </AlertTitle>
232
+ <AlertDescription>
233
+ {state.environmentBlocker === 'integration_disabled'
234
+ ? t('tillio.errors.integrationDisabled', 'The Tillio integration is disabled. Enable it first.')
235
+ : t('tillio.operators.envNotReadyText', 'Save the Tillio API URL and key in the Credentials tab, then run the health Check before attaching an operator.')}
236
+ </AlertDescription>
237
+ </Alert>
238
+ ) : null}
239
+
240
+ {operator && operator.stale ? (
241
+ <Alert status="warning">
242
+ <AlertTitle>{t('tillio.operators.driftTitle', 'Environment changed')}</AlertTitle>
243
+ <AlertDescription>
244
+ {t('tillio.operators.driftText', 'The environment changed after this operator was attached, so its token may be invalid. Detach and attach it again.')}
245
+ </AlertDescription>
246
+ </Alert>
247
+ ) : null}
248
+
249
+ {operator ? (
250
+ <div className="grid gap-3 rounded-md border border-border p-4">
251
+ <div className="flex items-center justify-between gap-3">
252
+ <div className="flex items-center gap-2">
253
+ <span className="font-medium">{operator.plugin}</span>
254
+ {operator.stale ? (
255
+ <StatusBadge variant="warning" dot>{t('tillio.operators.stale', 'Stale')}</StatusBadge>
256
+ ) : (
257
+ <StatusBadge variant="success" dot>{t('tillio.operators.active', 'Active')}</StatusBadge>
258
+ )}
259
+ </div>
260
+ <Button type="button" variant="outline" size="sm" onClick={() => void detach(operator.id)} disabled={pending}>
261
+ {t('tillio.operators.detach', 'Detach')}
262
+ </Button>
263
+ </div>
264
+ <div className="grid gap-1.5">
265
+ <Label asChild>
266
+ <span>{t('tillio.operators.webhookDomain', 'Webhook domain (X-Tenant-Domain)')}</span>
267
+ </Label>
268
+ <div className="flex items-center gap-2">
269
+ <code className="min-w-0 flex-1 truncate rounded-md bg-muted px-2 py-1 text-sm">{operator.tenantDomain}</code>
270
+ <Button type="button" variant="outline" size="sm" onClick={() => void copyTenantDomain(operator.tenantDomain)}>
271
+ {t('tillio.operators.copy', 'Copy')}
272
+ </Button>
273
+ </div>
274
+ <span className="text-xs text-muted-foreground">
275
+ {t('tillio.operators.webhookHint', 'Register this URL in your operator panel so Tillio forwards its webhooks here.')}
276
+ </span>
277
+ </div>
278
+ </div>
279
+ ) : state.environmentReady ? (
280
+ <div className="grid gap-3 rounded-md border border-border p-4">
281
+ <FormField
282
+ label={t('tillio.operators.ringostatKey', 'Ringostat key')}
283
+ description={t('tillio.operators.ringostatKeyHint', 'One operator at a time. Detach the current one before attaching another.')}
284
+ >
285
+ <Input
286
+ type="password"
287
+ autoComplete="off"
288
+ value={keyInput}
289
+ onChange={(event) => setKeyInput(event.target.value)}
290
+ placeholder={t('tillio.operators.ringostatKeyPlaceholder', 'Paste the Ringostat integration key')}
291
+ />
292
+ </FormField>
293
+ <div>
294
+ <Button type="button" onClick={() => void attach()} disabled={pending}>
295
+ {pending
296
+ ? t('tillio.operators.attaching', 'Attaching...')
297
+ : t('tillio.operators.attach', 'Attach operator')}
298
+ </Button>
299
+ </div>
300
+ </div>
301
+ ) : null}
302
+ </div>
303
+ )
304
+ }
@@ -0,0 +1,16 @@
1
+ import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'
2
+ import OperatorsConfigWidget from './widget.client'
3
+
4
+ const widget: InjectionWidgetModule<Record<string, unknown>, Record<string, unknown>> = {
5
+ metadata: {
6
+ id: 'tillio.injection.operators',
7
+ title: 'Operator configuration',
8
+ description: 'Attach one Tillio operator (Ringostat) on top of the configured environment.',
9
+ features: ['tillio.manage', 'integrations.manage'],
10
+ priority: 100,
11
+ enabled: true,
12
+ },
13
+ Widget: OperatorsConfigWidget,
14
+ }
15
+
16
+ export default widget