@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,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
|