@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,134 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { parseTillioTimestamp } from "./tz.js";
|
|
3
|
+
const stringish = z.union([z.string(), z.number()]).transform((value) => String(value));
|
|
4
|
+
const tillioCallSchema = z.object({
|
|
5
|
+
id: stringish,
|
|
6
|
+
date: stringish.optional(),
|
|
7
|
+
type: stringish.optional(),
|
|
8
|
+
caller: stringish.optional(),
|
|
9
|
+
destination: stringish.optional(),
|
|
10
|
+
status: stringish.optional(),
|
|
11
|
+
waitTime: stringish.optional(),
|
|
12
|
+
billSec: stringish.optional(),
|
|
13
|
+
extraFields: z.record(z.string(), z.unknown()).optional()
|
|
14
|
+
});
|
|
15
|
+
const ANSWERED_STATUSES = /* @__PURE__ */ new Set(["ANSWERED", "PROPER", "REPEATED", "CONNECTED"]);
|
|
16
|
+
const MISSED_STATUSES = /* @__PURE__ */ new Set(["NO ANSWER", "NO FORWARD", "VOICEMAIL", "NO EXTENSION", "BUSY"]);
|
|
17
|
+
const FAILED_STATUSES = /* @__PURE__ */ new Set(["FAILED"]);
|
|
18
|
+
const HTML_ENTITIES = {
|
|
19
|
+
""": '"',
|
|
20
|
+
"'": "'",
|
|
21
|
+
"'": "'",
|
|
22
|
+
"<": "<",
|
|
23
|
+
">": ">",
|
|
24
|
+
"&": "&"
|
|
25
|
+
};
|
|
26
|
+
function decodeHtmlEntities(value) {
|
|
27
|
+
return value.replace(/&(?:quot|apos|#39|lt|gt|amp);/g, (entity) => HTML_ENTITIES[entity] ?? entity);
|
|
28
|
+
}
|
|
29
|
+
function extractDisplayName(value) {
|
|
30
|
+
const decoded = decodeHtmlEntities(value).trim();
|
|
31
|
+
if (!decoded) return null;
|
|
32
|
+
const uriStart = decoded.indexOf("<");
|
|
33
|
+
const beforeUri = (uriStart >= 0 ? decoded.slice(0, uriStart) : decoded).trim();
|
|
34
|
+
const name = beforeUri.startsWith('"') && beforeUri.endsWith('"') ? beforeUri.slice(1, -1).trim() : beforeUri;
|
|
35
|
+
return name || null;
|
|
36
|
+
}
|
|
37
|
+
function readString(source, key) {
|
|
38
|
+
const value = source[key];
|
|
39
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
40
|
+
if (typeof value === "number") return String(value);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
function parseSeconds(value) {
|
|
44
|
+
if (value === void 0) return null;
|
|
45
|
+
const parsed = Number(value);
|
|
46
|
+
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
|
47
|
+
return Math.trunc(parsed);
|
|
48
|
+
}
|
|
49
|
+
function mapDirection(type) {
|
|
50
|
+
switch ((type ?? "").trim().toUpperCase()) {
|
|
51
|
+
case "IN":
|
|
52
|
+
return "inbound";
|
|
53
|
+
case "OUT":
|
|
54
|
+
return "outbound";
|
|
55
|
+
default:
|
|
56
|
+
return "unknown";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function mapStatus(status, durationSeconds) {
|
|
60
|
+
if (durationSeconds !== null && durationSeconds > 0) return "answered";
|
|
61
|
+
const normalized = (status ?? "").trim().toUpperCase();
|
|
62
|
+
if (ANSWERED_STATUSES.has(normalized)) return "answered";
|
|
63
|
+
if (MISSED_STATUSES.has(normalized)) return "missed";
|
|
64
|
+
if (FAILED_STATUSES.has(normalized)) return "failed";
|
|
65
|
+
return "unknown";
|
|
66
|
+
}
|
|
67
|
+
const ringostatExtractor = (extraFields) => {
|
|
68
|
+
const providerFacts = {};
|
|
69
|
+
const callCard = readString(extraFields, "call_card");
|
|
70
|
+
if (callCard) providerFacts.tillioCallCardUrl = callCard;
|
|
71
|
+
const recordingUrl = readString(extraFields, "recording");
|
|
72
|
+
const hasRecording = readString(extraFields, "has_recording") === "1" && Boolean(recordingUrl);
|
|
73
|
+
const rawCaller = readString(extraFields, "caller");
|
|
74
|
+
return {
|
|
75
|
+
callerDisplayName: rawCaller ? extractDisplayName(rawCaller) : null,
|
|
76
|
+
recording: hasRecording ? { url: recordingUrl } : null,
|
|
77
|
+
providerFacts
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
const EXTRACTORS = {
|
|
81
|
+
Ringostat: ringostatExtractor
|
|
82
|
+
};
|
|
83
|
+
function getExtractorForPlugin(plugin) {
|
|
84
|
+
return EXTRACTORS[plugin];
|
|
85
|
+
}
|
|
86
|
+
function normalizeTillioCall(rawCall, context) {
|
|
87
|
+
const parsed = tillioCallSchema.safeParse(rawCall);
|
|
88
|
+
if (!parsed.success) {
|
|
89
|
+
throw new Error("[internal] Tillio returned a call payload without a usable id.");
|
|
90
|
+
}
|
|
91
|
+
const call = parsed.data;
|
|
92
|
+
const extraction = getExtractorForPlugin(context.plugin)(call.extraFields ?? {});
|
|
93
|
+
const durationSeconds = parseSeconds(call.billSec);
|
|
94
|
+
const waitTimeSeconds = parseSeconds(call.waitTime);
|
|
95
|
+
const participants = [];
|
|
96
|
+
if (call.caller) {
|
|
97
|
+
participants.push({
|
|
98
|
+
role: "caller",
|
|
99
|
+
phoneNumber: call.caller,
|
|
100
|
+
displayName: extraction.callerDisplayName ?? null
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (call.destination) {
|
|
104
|
+
participants.push({ role: "callee", phoneNumber: call.destination });
|
|
105
|
+
}
|
|
106
|
+
const providerFacts = {
|
|
107
|
+
...extraction.providerFacts,
|
|
108
|
+
tillioStatus: call.status ?? null,
|
|
109
|
+
tillioType: call.type ?? null,
|
|
110
|
+
operatorId: context.operatorId,
|
|
111
|
+
plugin: context.plugin
|
|
112
|
+
};
|
|
113
|
+
if (waitTimeSeconds !== null) providerFacts.tillioWaitTimeSeconds = waitTimeSeconds;
|
|
114
|
+
return {
|
|
115
|
+
externalCallId: call.id,
|
|
116
|
+
direction: mapDirection(call.type),
|
|
117
|
+
status: mapStatus(call.status, durationSeconds),
|
|
118
|
+
participants,
|
|
119
|
+
recording: extraction.recording ?? null,
|
|
120
|
+
startedAt: parseTillioTimestamp(call.date, context.timeZone),
|
|
121
|
+
answeredAt: null,
|
|
122
|
+
endedAt: null,
|
|
123
|
+
durationSeconds,
|
|
124
|
+
providerFacts,
|
|
125
|
+
rawPayload: rawCall
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export {
|
|
129
|
+
getExtractorForPlugin,
|
|
130
|
+
mapStatus,
|
|
131
|
+
normalizeTillioCall,
|
|
132
|
+
ringostatExtractor
|
|
133
|
+
};
|
|
134
|
+
//# sourceMappingURL=normalizer.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/tillio/lib/normalizer.ts"],
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport type {\n NormalizedPhoneCall,\n NormalizedPhoneCallParticipant,\n NormalizedPhoneCallRecording,\n PhoneCallDirection,\n PhoneCallStatus,\n} from '@open-mercato/shared/modules/phone_calls/types'\nimport type { TillioOperatorPlugin } from './operators-store'\nimport { parseTillioTimestamp } from './tz'\n\nconst stringish = z.union([z.string(), z.number()]).transform((value) => String(value))\n\nconst tillioCallSchema = z.object({\n id: stringish,\n date: stringish.optional(),\n type: stringish.optional(),\n caller: stringish.optional(),\n destination: stringish.optional(),\n status: stringish.optional(),\n waitTime: stringish.optional(),\n billSec: stringish.optional(),\n extraFields: z.record(z.string(), z.unknown()).optional(),\n})\n\nexport type TillioRawCall = z.infer<typeof tillioCallSchema>\n\nconst ANSWERED_STATUSES = new Set(['ANSWERED', 'PROPER', 'REPEATED', 'CONNECTED'])\n// The call reached the callee side but no conversation happened: nobody picked up,\n// voicemail took it, there was nowhere to forward it, or the callee was on another\n// call.\nconst MISSED_STATUSES = new Set(['NO ANSWER', 'NO FORWARD', 'VOICEMAIL', 'NO EXTENSION', 'BUSY'])\n// The call could not be set up at all (SIP 603).\nconst FAILED_STATUSES = new Set(['FAILED'])\n\nconst HTML_ENTITIES: Record<string, string> = {\n '"': '\"',\n ''': \"'\",\n ''': \"'\",\n '<': '<',\n '>': '>',\n '&': '&',\n}\n\nexport type TillioCallExtraction = {\n callerDisplayName?: string | null\n recording?: NormalizedPhoneCallRecording | null\n providerFacts?: Record<string, unknown>\n}\n\nexport type TillioCallExtractor = (extraFields: Record<string, unknown>) => TillioCallExtraction\n\nexport type NormalizeContext = {\n operatorId: string\n plugin: TillioOperatorPlugin\n timeZone?: string\n}\n\nfunction decodeHtmlEntities(value: string): string {\n return value.replace(/&(?:quot|apos|#39|lt|gt|amp);/g, (entity) => HTML_ENTITIES[entity] ?? entity)\n}\n\n// Ringostat sends a SIP-style `\"display name\" <uri>` string, HTML-escaped.\nfunction extractDisplayName(value: string): string | null {\n const decoded = decodeHtmlEntities(value).trim()\n if (!decoded) return null\n const uriStart = decoded.indexOf('<')\n const beforeUri = (uriStart >= 0 ? decoded.slice(0, uriStart) : decoded).trim()\n const name = beforeUri.startsWith('\"') && beforeUri.endsWith('\"')\n ? beforeUri.slice(1, -1).trim()\n : beforeUri\n return name || null\n}\n\nfunction readString(source: Record<string, unknown>, key: string): string | null {\n const value = source[key]\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number') return String(value)\n return null\n}\n\nfunction parseSeconds(value: string | undefined): number | null {\n if (value === undefined) return null\n const parsed = Number(value)\n if (!Number.isFinite(parsed) || parsed < 0) return null\n return Math.trunc(parsed)\n}\n\nfunction mapDirection(type: string | undefined): PhoneCallDirection {\n switch ((type ?? '').trim().toUpperCase()) {\n case 'IN':\n return 'inbound'\n case 'OUT':\n return 'outbound'\n default:\n return 'unknown'\n }\n}\n\n// A billed call connected, whatever the textual status claims. A status Tillio has\n// not documented yet stays 'unknown' rather than being reported as a failure.\nexport function mapStatus(status: string | undefined, durationSeconds: number | null): PhoneCallStatus {\n if (durationSeconds !== null && durationSeconds > 0) return 'answered'\n const normalized = (status ?? '').trim().toUpperCase()\n if (ANSWERED_STATUSES.has(normalized)) return 'answered'\n if (MISSED_STATUSES.has(normalized)) return 'missed'\n if (FAILED_STATUSES.has(normalized)) return 'failed'\n return 'unknown'\n}\n\nexport const ringostatExtractor: TillioCallExtractor = (extraFields) => {\n const providerFacts: Record<string, unknown> = {}\n const callCard = readString(extraFields, 'call_card')\n if (callCard) providerFacts.tillioCallCardUrl = callCard\n\n const recordingUrl = readString(extraFields, 'recording')\n const hasRecording = readString(extraFields, 'has_recording') === '1' && Boolean(recordingUrl)\n\n const rawCaller = readString(extraFields, 'caller')\n\n return {\n callerDisplayName: rawCaller ? extractDisplayName(rawCaller) : null,\n recording: hasRecording ? { url: recordingUrl } : null,\n providerFacts,\n }\n}\n\nconst EXTRACTORS: Record<TillioOperatorPlugin, TillioCallExtractor> = {\n Ringostat: ringostatExtractor,\n}\n\nexport function getExtractorForPlugin(plugin: TillioOperatorPlugin): TillioCallExtractor {\n return EXTRACTORS[plugin]\n}\n\nexport function normalizeTillioCall(\n rawCall: Record<string, unknown>,\n context: NormalizeContext,\n): NormalizedPhoneCall {\n const parsed = tillioCallSchema.safeParse(rawCall)\n if (!parsed.success) {\n throw new Error('[internal] Tillio returned a call payload without a usable id.')\n }\n const call = parsed.data\n const extraction = getExtractorForPlugin(context.plugin)(call.extraFields ?? {})\n\n const durationSeconds = parseSeconds(call.billSec)\n const waitTimeSeconds = parseSeconds(call.waitTime)\n\n const participants: NormalizedPhoneCallParticipant[] = []\n if (call.caller) {\n participants.push({\n role: 'caller',\n phoneNumber: call.caller,\n displayName: extraction.callerDisplayName ?? null,\n })\n }\n if (call.destination) {\n participants.push({ role: 'callee', phoneNumber: call.destination })\n }\n\n const providerFacts: Record<string, unknown> = {\n ...extraction.providerFacts,\n tillioStatus: call.status ?? null,\n tillioType: call.type ?? null,\n operatorId: context.operatorId,\n plugin: context.plugin,\n }\n if (waitTimeSeconds !== null) providerFacts.tillioWaitTimeSeconds = waitTimeSeconds\n\n return {\n externalCallId: call.id,\n direction: mapDirection(call.type),\n status: mapStatus(call.status, durationSeconds),\n participants,\n recording: extraction.recording ?? null,\n startedAt: parseTillioTimestamp(call.date, context.timeZone),\n answeredAt: null,\n endedAt: null,\n durationSeconds,\n providerFacts,\n rawPayload: rawCall,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { scryptSync } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const TILLIO_OPERATORS_INTEGRATION_ID = "tillio_operators";
|
|
4
|
+
const operatorPluginSchema = z.enum(["Ringostat"]);
|
|
5
|
+
const ringostatConfigSchema = z.object({
|
|
6
|
+
key: z.string().trim().min(1)
|
|
7
|
+
});
|
|
8
|
+
const operatorRecordSchema = z.object({
|
|
9
|
+
id: z.string().trim().min(1),
|
|
10
|
+
plugin: operatorPluginSchema,
|
|
11
|
+
label: z.string().trim().optional(),
|
|
12
|
+
config: z.record(z.string(), z.unknown()),
|
|
13
|
+
token: z.string().trim().min(1),
|
|
14
|
+
tenantDomain: z.string().trim().min(1),
|
|
15
|
+
envFingerprint: z.string().trim().min(1)
|
|
16
|
+
});
|
|
17
|
+
const operatorsBlobSchema = z.object({
|
|
18
|
+
operators: z.array(operatorRecordSchema).default([]),
|
|
19
|
+
defaultOperatorId: z.string().trim().nullable().default(null)
|
|
20
|
+
});
|
|
21
|
+
const EMPTY_BLOB = { operators: [], defaultOperatorId: null };
|
|
22
|
+
function computeEnvFingerprint(env) {
|
|
23
|
+
const material = `${env.tenantSystemId}
|
|
24
|
+
${env.apiUrl}
|
|
25
|
+
${env.apiKey}`;
|
|
26
|
+
return scryptSync(material, "open-mercato:tillio:environment-fingerprint:v1", 32).toString("hex");
|
|
27
|
+
}
|
|
28
|
+
function resolveAppHost(appUrl) {
|
|
29
|
+
const trimmed = appUrl.trim();
|
|
30
|
+
if (!trimmed) return "";
|
|
31
|
+
try {
|
|
32
|
+
return new URL(trimmed).host;
|
|
33
|
+
} catch {
|
|
34
|
+
return trimmed.replace(/^\/+/, "").split("/")[0] ?? "";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function buildTenantDomain(appUrl, tenantSystemId, operatorId) {
|
|
38
|
+
const host = resolveAppHost(appUrl);
|
|
39
|
+
if (!host) throw new Error("[internal] APP_URL is not configured; cannot build the Tillio X-Tenant-Domain.");
|
|
40
|
+
return `${host}/${tenantSystemId}-${operatorId}`;
|
|
41
|
+
}
|
|
42
|
+
async function readOperatorsBlob(credentialsService, scope) {
|
|
43
|
+
const raw = await credentialsService.getRaw(TILLIO_OPERATORS_INTEGRATION_ID, scope);
|
|
44
|
+
const parsed = operatorsBlobSchema.safeParse(raw ?? {});
|
|
45
|
+
return parsed.success ? parsed.data : EMPTY_BLOB;
|
|
46
|
+
}
|
|
47
|
+
async function saveOperatorsBlob(credentialsService, scope, blob) {
|
|
48
|
+
await credentialsService.save(TILLIO_OPERATORS_INTEGRATION_ID, blob, scope);
|
|
49
|
+
}
|
|
50
|
+
export {
|
|
51
|
+
TILLIO_OPERATORS_INTEGRATION_ID,
|
|
52
|
+
buildTenantDomain,
|
|
53
|
+
computeEnvFingerprint,
|
|
54
|
+
operatorPluginSchema,
|
|
55
|
+
operatorRecordSchema,
|
|
56
|
+
operatorsBlobSchema,
|
|
57
|
+
readOperatorsBlob,
|
|
58
|
+
resolveAppHost,
|
|
59
|
+
ringostatConfigSchema,
|
|
60
|
+
saveOperatorsBlob
|
|
61
|
+
};
|
|
62
|
+
//# sourceMappingURL=operators-store.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/tillio/lib/operators-store.ts"],
|
|
4
|
+
"sourcesContent": ["import { scryptSync } from 'node:crypto'\nimport { z } from 'zod'\nimport type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'\n\n// Own key in the credentials store rather than a table: `integration_credentials.integration_id` is\n// plain text with no registry lookup, so a provider key is accepted and the token inherits the\n// store's encryption. Distinct from TILLIO_INTEGRATION_ID so detaching never touches the environment.\nexport const TILLIO_OPERATORS_INTEGRATION_ID = 'tillio_operators'\n\nexport const operatorPluginSchema = z.enum(['Ringostat'])\nexport type TillioOperatorPlugin = z.infer<typeof operatorPluginSchema>\n\nexport const ringostatConfigSchema = z.object({\n key: z.string().trim().min(1),\n})\n\nexport const operatorRecordSchema = z.object({\n id: z.string().trim().min(1),\n plugin: operatorPluginSchema,\n label: z.string().trim().optional(),\n config: z.record(z.string(), z.unknown()),\n token: z.string().trim().min(1),\n tenantDomain: z.string().trim().min(1),\n envFingerprint: z.string().trim().min(1),\n})\nexport type TillioOperatorRecord = z.infer<typeof operatorRecordSchema>\n\nexport const operatorsBlobSchema = z.object({\n operators: z.array(operatorRecordSchema).default([]),\n defaultOperatorId: z.string().trim().nullable().default(null),\n})\nexport type TillioOperatorsBlob = z.infer<typeof operatorsBlobSchema>\n\nconst EMPTY_BLOB: TillioOperatorsBlob = { operators: [], defaultOperatorId: null }\n\nexport type TillioCredentialsService = {\n getRaw: (integrationId: string, scope: IntegrationScope) => Promise<Record<string, unknown> | null>\n save: (integrationId: string, credentials: Record<string, unknown>, scope: IntegrationScope) => Promise<void>\n}\n\nexport function computeEnvFingerprint(env: { tenantSystemId: string; apiUrl: string; apiKey: string }): string {\n const material = `${env.tenantSystemId}\\n${env.apiUrl}\\n${env.apiKey}`\n return scryptSync(material, 'open-mercato:tillio:environment-fingerprint:v1', 32).toString('hex')\n}\n\nexport function resolveAppHost(appUrl: string): string {\n const trimmed = appUrl.trim()\n if (!trimmed) return ''\n try {\n return new URL(trimmed).host\n } catch {\n return trimmed.replace(/^\\/+/, '').split('/')[0] ?? ''\n }\n}\n\nexport function buildTenantDomain(appUrl: string, tenantSystemId: string, operatorId: string): string {\n const host = resolveAppHost(appUrl)\n if (!host) throw new Error('[internal] APP_URL is not configured; cannot build the Tillio X-Tenant-Domain.')\n return `${host}/${tenantSystemId}-${operatorId}`\n}\n\nexport async function readOperatorsBlob(\n credentialsService: TillioCredentialsService,\n scope: IntegrationScope,\n): Promise<TillioOperatorsBlob> {\n const raw = await credentialsService.getRaw(TILLIO_OPERATORS_INTEGRATION_ID, scope)\n const parsed = operatorsBlobSchema.safeParse(raw ?? {})\n return parsed.success ? parsed.data : EMPTY_BLOB\n}\n\nexport async function saveOperatorsBlob(\n credentialsService: TillioCredentialsService,\n scope: IntegrationScope,\n blob: TillioOperatorsBlob,\n): Promise<void> {\n await credentialsService.save(TILLIO_OPERATORS_INTEGRATION_ID, blob, scope)\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,SAAS;AAMX,MAAM,kCAAkC;AAExC,MAAM,uBAAuB,EAAE,KAAK,CAAC,WAAW,CAAC;AAGjD,MAAM,wBAAwB,EAAE,OAAO;AAAA,EAC5C,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC;AAC9B,CAAC;AAEM,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC;AAAA,EAC3B,QAAQ;AAAA,EACR,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EAClC,QAAQ,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,QAAQ,CAAC;AAAA,EACxC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC;AAAA,EAC9B,cAAc,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC;AAAA,EACrC,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC;AACzC,CAAC;AAGM,MAAM,sBAAsB,EAAE,OAAO;AAAA,EAC1C,WAAW,EAAE,MAAM,oBAAoB,EAAE,QAAQ,CAAC,CAAC;AAAA,EACnD,mBAAmB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,IAAI;AAC9D,CAAC;AAGD,MAAM,aAAkC,EAAE,WAAW,CAAC,GAAG,mBAAmB,KAAK;AAO1E,SAAS,sBAAsB,KAAyE;AAC7G,QAAM,WAAW,GAAG,IAAI,cAAc;AAAA,EAAK,IAAI,MAAM;AAAA,EAAK,IAAI,MAAM;AACpE,SAAO,WAAW,UAAU,kDAAkD,EAAE,EAAE,SAAS,KAAK;AAClG;AAEO,SAAS,eAAe,QAAwB;AACrD,QAAM,UAAU,OAAO,KAAK;AAC5B,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI;AACF,WAAO,IAAI,IAAI,OAAO,EAAE;AAAA,EAC1B,QAAQ;AACN,WAAO,QAAQ,QAAQ,QAAQ,EAAE,EAAE,MAAM,GAAG,EAAE,CAAC,KAAK;AAAA,EACtD;AACF;AAEO,SAAS,kBAAkB,QAAgB,gBAAwB,YAA4B;AACpG,QAAM,OAAO,eAAe,MAAM;AAClC,MAAI,CAAC,KAAM,OAAM,IAAI,MAAM,gFAAgF;AAC3G,SAAO,GAAG,IAAI,IAAI,cAAc,IAAI,UAAU;AAChD;AAEA,eAAsB,kBACpB,oBACA,OAC8B;AAC9B,QAAM,MAAM,MAAM,mBAAmB,OAAO,iCAAiC,KAAK;AAClF,QAAM,SAAS,oBAAoB,UAAU,OAAO,CAAC,CAAC;AACtD,SAAO,OAAO,UAAU,OAAO,OAAO;AACxC;AAEA,eAAsB,kBACpB,oBACA,OACA,MACe;AACf,QAAM,mBAAmB,KAAK,iCAAiC,MAAM,KAAK;AAC5E;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
|
+
import { TillioApiError } from "./errors.js";
|
|
3
|
+
import { createTillioClient } from "./client.js";
|
|
4
|
+
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
5
|
+
import { IntegrationState } from "@open-mercato/core/modules/integrations/data/entities";
|
|
6
|
+
import { environmentSchema, readTenantSystemId } from "./environment.js";
|
|
7
|
+
import { TILLIO_TIMEZONE } from "./tz.js";
|
|
8
|
+
import { TILLIO_INTEGRATION_ID } from "../integration.js";
|
|
9
|
+
import {
|
|
10
|
+
buildTenantDomain,
|
|
11
|
+
computeEnvFingerprint,
|
|
12
|
+
operatorPluginSchema,
|
|
13
|
+
readOperatorsBlob,
|
|
14
|
+
ringostatConfigSchema,
|
|
15
|
+
saveOperatorsBlob
|
|
16
|
+
} from "./operators-store.js";
|
|
17
|
+
const logger = createLogger("tillio").child({ component: "operators" });
|
|
18
|
+
const TILLIO_OPERATOR_RESOURCE_KIND = "tillio.operator";
|
|
19
|
+
class TillioEnvironmentNotReadyError extends Error {
|
|
20
|
+
constructor(message = "The Tillio environment is not ready. Save credentials and run the health check first.") {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "TillioEnvironmentNotReadyError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
class TillioOperatorLimitError extends Error {
|
|
26
|
+
constructor(message = "An operator is already attached. Detach it before attaching another one.") {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "TillioOperatorLimitError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
class TillioRevocationFailedError extends Error {
|
|
32
|
+
constructor(environmentMissing, message) {
|
|
33
|
+
super(
|
|
34
|
+
message ?? (environmentMissing ? "The Tillio environment is no longer configured, so the operator token cannot be revoked." : "Tillio did not confirm the operator token was revoked.")
|
|
35
|
+
);
|
|
36
|
+
this.name = "TillioRevocationFailedError";
|
|
37
|
+
this.environmentMissing = environmentMissing;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function classifyTillioError(err) {
|
|
41
|
+
if (err instanceof TillioApiError) {
|
|
42
|
+
if (err.status === 0 || err.status === 401 || err.status === 403) return "environment";
|
|
43
|
+
}
|
|
44
|
+
return "operator";
|
|
45
|
+
}
|
|
46
|
+
async function resolveEnvironment(credentialsService, scope) {
|
|
47
|
+
const raw = await credentialsService.getRaw(TILLIO_INTEGRATION_ID, scope);
|
|
48
|
+
const parsed = environmentSchema.safeParse(raw ?? {});
|
|
49
|
+
if (!parsed.success) return null;
|
|
50
|
+
const tenantSystemId = await readTenantSystemId(credentialsService, scope) ?? parsed.data.tenantSystemId;
|
|
51
|
+
if (!tenantSystemId) return null;
|
|
52
|
+
return {
|
|
53
|
+
apiUrl: parsed.data.apiUrl,
|
|
54
|
+
apiKey: parsed.data.apiKey,
|
|
55
|
+
tenantSystemId,
|
|
56
|
+
timeZone: parsed.data.timeZone ?? TILLIO_TIMEZONE
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function readTillioIntegrationState(em, scope) {
|
|
60
|
+
const state = await findOneWithDecryption(
|
|
61
|
+
em,
|
|
62
|
+
IntegrationState,
|
|
63
|
+
{
|
|
64
|
+
integrationId: TILLIO_INTEGRATION_ID,
|
|
65
|
+
organizationId: scope.organizationId,
|
|
66
|
+
tenantId: scope.tenantId,
|
|
67
|
+
deletedAt: null
|
|
68
|
+
},
|
|
69
|
+
void 0,
|
|
70
|
+
{ tenantId: scope.tenantId, organizationId: scope.organizationId }
|
|
71
|
+
);
|
|
72
|
+
return {
|
|
73
|
+
enabled: state?.isEnabled ?? false,
|
|
74
|
+
healthy: state?.lastHealthStatus === "healthy"
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function operatorIdForPlugin(plugin) {
|
|
78
|
+
return `${plugin.toLowerCase()}-1`;
|
|
79
|
+
}
|
|
80
|
+
function normalizeOperatorConfig(plugin, config) {
|
|
81
|
+
if (plugin === "Ringostat") return ringostatConfigSchema.parse(config);
|
|
82
|
+
return config;
|
|
83
|
+
}
|
|
84
|
+
async function attachOperator(deps, input) {
|
|
85
|
+
const plugin = operatorPluginSchema.parse(input.plugin);
|
|
86
|
+
const config = normalizeOperatorConfig(plugin, input.config);
|
|
87
|
+
const environment = await resolveEnvironment(deps.credentialsService, deps.scope);
|
|
88
|
+
if (!environment) throw new TillioEnvironmentNotReadyError();
|
|
89
|
+
const blob = await readOperatorsBlob(deps.credentialsService, deps.scope);
|
|
90
|
+
if (blob.operators.length > 0) throw new TillioOperatorLimitError();
|
|
91
|
+
const operatorId = operatorIdForPlugin(plugin);
|
|
92
|
+
const tenantDomain = buildTenantDomain(deps.appUrl, environment.tenantSystemId, operatorId);
|
|
93
|
+
const client = createTillioClient(environment);
|
|
94
|
+
await client.validateConfig(plugin, config, tenantDomain);
|
|
95
|
+
const { token } = await client.addConfig(plugin, config, tenantDomain);
|
|
96
|
+
const record = {
|
|
97
|
+
id: operatorId,
|
|
98
|
+
plugin,
|
|
99
|
+
...input.label ? { label: input.label } : {},
|
|
100
|
+
config,
|
|
101
|
+
token,
|
|
102
|
+
tenantDomain,
|
|
103
|
+
// Pins the operator to the environment that issued its token. If the environment is later
|
|
104
|
+
// edited the pairing is stale, and comparing this fingerprint is what raises `environment_drift`
|
|
105
|
+
// instead of pulling against an instance the operator never belonged to.
|
|
106
|
+
envFingerprint: computeEnvFingerprint(environment)
|
|
107
|
+
};
|
|
108
|
+
try {
|
|
109
|
+
await deps.withLock(async () => {
|
|
110
|
+
const current = await readOperatorsBlob(deps.credentialsService, deps.scope);
|
|
111
|
+
if (current.operators.length > 0) throw new TillioOperatorLimitError();
|
|
112
|
+
await saveOperatorsBlob(deps.credentialsService, deps.scope, {
|
|
113
|
+
operators: [record],
|
|
114
|
+
defaultOperatorId: operatorId
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
await client.deleteConfig(plugin, token, tenantDomain).catch((revokeErr) => {
|
|
119
|
+
logger.error("could not roll back the remote operator config", { operatorId, err: revokeErr });
|
|
120
|
+
});
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
return record;
|
|
124
|
+
}
|
|
125
|
+
async function detachOperator(deps, operatorId, options = {}) {
|
|
126
|
+
const blob = await readOperatorsBlob(deps.credentialsService, deps.scope);
|
|
127
|
+
const operator = blob.operators.find((entry) => entry.id === operatorId);
|
|
128
|
+
if (!operator) return { ok: true, detached: false, revoked: false };
|
|
129
|
+
const environment = await resolveEnvironment(deps.credentialsService, deps.scope);
|
|
130
|
+
let revoked = false;
|
|
131
|
+
if (!environment) {
|
|
132
|
+
if (!options.force) throw new TillioRevocationFailedError(true);
|
|
133
|
+
} else {
|
|
134
|
+
try {
|
|
135
|
+
const client = createTillioClient(environment);
|
|
136
|
+
await client.deleteConfig(operator.plugin, operator.token, operator.tenantDomain);
|
|
137
|
+
revoked = true;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logger.error("could not revoke the Tillio operator token", {
|
|
140
|
+
operatorId,
|
|
141
|
+
status: err instanceof TillioApiError ? err.status : void 0,
|
|
142
|
+
detail: err instanceof TillioApiError ? err.detail : void 0,
|
|
143
|
+
err
|
|
144
|
+
});
|
|
145
|
+
if (!options.force) {
|
|
146
|
+
throw new TillioRevocationFailedError(false);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const remaining = blob.operators.filter((entry) => entry.id !== operatorId);
|
|
151
|
+
await saveOperatorsBlob(deps.credentialsService, deps.scope, {
|
|
152
|
+
operators: remaining,
|
|
153
|
+
defaultOperatorId: blob.defaultOperatorId === operatorId ? remaining[0]?.id ?? null : blob.defaultOperatorId
|
|
154
|
+
});
|
|
155
|
+
return { ok: true, detached: true, revoked };
|
|
156
|
+
}
|
|
157
|
+
export {
|
|
158
|
+
TILLIO_OPERATOR_RESOURCE_KIND,
|
|
159
|
+
TillioEnvironmentNotReadyError,
|
|
160
|
+
TillioOperatorLimitError,
|
|
161
|
+
TillioRevocationFailedError,
|
|
162
|
+
attachOperator,
|
|
163
|
+
classifyTillioError,
|
|
164
|
+
detachOperator,
|
|
165
|
+
readTillioIntegrationState,
|
|
166
|
+
resolveEnvironment
|
|
167
|
+
};
|
|
168
|
+
//# sourceMappingURL=operators.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/tillio/lib/operators.ts"],
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'\nimport { TillioApiError } from './errors'\nimport { createTillioClient } from './client'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { IntegrationState } from '@open-mercato/core/modules/integrations/data/entities'\nimport { environmentSchema, readTenantSystemId } from './environment'\nimport type { TillioLockRunner } from './locking'\nimport { TILLIO_TIMEZONE } from './tz'\nimport { TILLIO_INTEGRATION_ID } from '../integration'\nimport {\n buildTenantDomain,\n computeEnvFingerprint,\n operatorPluginSchema,\n readOperatorsBlob,\n ringostatConfigSchema,\n saveOperatorsBlob,\n type TillioCredentialsService,\n type TillioOperatorPlugin,\n type TillioOperatorRecord,\n} from './operators-store'\n\nconst logger = createLogger('tillio').child({ component: 'operators' })\n\nexport type TillioResolvedEnvironment = {\n apiUrl: string\n apiKey: string\n tenantSystemId: string\n timeZone: string\n}\n\nexport const TILLIO_OPERATOR_RESOURCE_KIND = 'tillio.operator'\n\nexport type OperatorErrorSection = 'environment' | 'operator'\n\nexport class TillioEnvironmentNotReadyError extends Error {\n constructor(message = 'The Tillio environment is not ready. Save credentials and run the health check first.') {\n super(message)\n this.name = 'TillioEnvironmentNotReadyError'\n }\n}\n\nexport class TillioOperatorLimitError extends Error {\n constructor(message = 'An operator is already attached. Detach it before attaching another one.') {\n super(message)\n this.name = 'TillioOperatorLimitError'\n }\n}\n\n/**\n * Raised when the operator's token could not be revoked at Tillio. The local record stays in\n * place, because dropping it would leave a live token nobody holds a handle to any more.\n * `environmentMissing` separates the two outcomes for the caller: a provider failure is worth\n * retrying, a missing environment is not \u2014 it needs the credentials fixed, or a forced detach.\n */\nexport class TillioRevocationFailedError extends Error {\n readonly environmentMissing: boolean\n\n constructor(environmentMissing: boolean, message?: string) {\n super(\n message\n ?? (environmentMissing\n ? 'The Tillio environment is no longer configured, so the operator token cannot be revoked.'\n : 'Tillio did not confirm the operator token was revoked.'),\n )\n this.name = 'TillioRevocationFailedError'\n this.environmentMissing = environmentMissing\n }\n}\n\nexport function classifyTillioError(err: unknown): OperatorErrorSection {\n if (err instanceof TillioApiError) {\n if (err.status === 0 || err.status === 401 || err.status === 403) return 'environment'\n }\n return 'operator'\n}\n\nexport async function resolveEnvironment(\n credentialsService: TillioCredentialsService,\n scope: IntegrationScope,\n): Promise<TillioResolvedEnvironment | null> {\n const raw = await credentialsService.getRaw(TILLIO_INTEGRATION_ID, scope)\n const parsed = environmentSchema.safeParse(raw ?? {})\n if (!parsed.success) return null\n // Falls back to the legacy location until the next health check moves the identity across.\n const tenantSystemId = (await readTenantSystemId(credentialsService, scope)) ?? parsed.data.tenantSystemId\n if (!tenantSystemId) return null\n return {\n apiUrl: parsed.data.apiUrl,\n apiKey: parsed.data.apiKey,\n tenantSystemId,\n timeZone: parsed.data.timeZone ?? TILLIO_TIMEZONE,\n }\n}\n\nexport type TillioIntegrationState = {\n /** The administrator's switch. Provisioning and sweeps stay off while it is false. */\n enabled: boolean\n /** The last probe result. Says Tillio answers, not that anybody wants us talking to it. */\n healthy: boolean\n}\n\n// Both signals come off one row, and they fail for different reasons a user fixes in different\n// places, so callers get them apart rather than folded into a single boolean. A missing row\n// reads as disabled, matching `integrationStateService.resolveState`, which falls back to the\n// descriptor's `defaultState` and to `false` without one. The Tillio descriptor declares no\n// `defaultState`, so adding one there means switching this over to that service.\nexport async function readTillioIntegrationState(\n em: EntityManager,\n scope: IntegrationScope,\n): Promise<TillioIntegrationState> {\n const state = await findOneWithDecryption(\n em,\n IntegrationState,\n {\n integrationId: TILLIO_INTEGRATION_ID,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId },\n )\n return {\n enabled: state?.isEnabled ?? false,\n healthy: state?.lastHealthStatus === 'healthy',\n }\n}\n\nfunction operatorIdForPlugin(plugin: TillioOperatorPlugin): string {\n return `${plugin.toLowerCase()}-1`\n}\n\nfunction normalizeOperatorConfig(plugin: TillioOperatorPlugin, config: Record<string, unknown>): Record<string, unknown> {\n if (plugin === 'Ringostat') return ringostatConfigSchema.parse(config)\n return config\n}\n\nexport type AttachOperatorDeps = {\n credentialsService: TillioCredentialsService\n scope: IntegrationScope\n appUrl: string\n /**\n * Serializes the slot check and the write. Required rather than optional so a new call site\n * has to decide: passing `(run) => run()` says the caller knows it cannot contend.\n */\n withLock: TillioLockRunner\n}\n\nexport type AttachOperatorInput = {\n plugin: TillioOperatorPlugin\n config: Record<string, unknown>\n label?: string\n}\n\nexport async function attachOperator(\n deps: AttachOperatorDeps,\n input: AttachOperatorInput,\n): Promise<TillioOperatorRecord> {\n const plugin = operatorPluginSchema.parse(input.plugin)\n const config = normalizeOperatorConfig(plugin, input.config)\n\n const environment = await resolveEnvironment(deps.credentialsService, deps.scope)\n if (!environment) throw new TillioEnvironmentNotReadyError()\n\n const blob = await readOperatorsBlob(deps.credentialsService, deps.scope)\n if (blob.operators.length > 0) throw new TillioOperatorLimitError()\n\n const operatorId = operatorIdForPlugin(plugin)\n const tenantDomain = buildTenantDomain(deps.appUrl, environment.tenantSystemId, operatorId)\n const client = createTillioClient(environment)\n\n await client.validateConfig(plugin, config, tenantDomain)\n const { token } = await client.addConfig(plugin, config, tenantDomain)\n\n const record: TillioOperatorRecord = {\n id: operatorId,\n plugin,\n ...(input.label ? { label: input.label } : {}),\n config,\n token,\n tenantDomain,\n // Pins the operator to the environment that issued its token. If the environment is later\n // edited the pairing is stale, and comparing this fingerprint is what raises `environment_drift`\n // instead of pulling against an instance the operator never belonged to.\n envFingerprint: computeEnvFingerprint(environment),\n }\n\n try {\n // The remote calls above stay outside the lock: holding a database transaction while\n // waiting on Tillio would tie a connection to their latency. Inside it, the slot is\n // re-checked and written as one step, so a concurrent attach either loses here and is\n // undone below, or wins and is the only stored operator.\n await deps.withLock(async () => {\n const current = await readOperatorsBlob(deps.credentialsService, deps.scope)\n if (current.operators.length > 0) throw new TillioOperatorLimitError()\n\n await saveOperatorsBlob(deps.credentialsService, deps.scope, {\n operators: [record],\n defaultOperatorId: operatorId,\n })\n })\n } catch (err) {\n // `addConfig` already registered this operator on Tillio's side. If we cannot persist it\n // locally we would lose the only reference to that remote config, so undo it before failing.\n await client.deleteConfig(plugin, token, tenantDomain).catch((revokeErr: unknown) => {\n logger.error('could not roll back the remote operator config', { operatorId, err: revokeErr })\n })\n throw err\n }\n\n return record\n}\n\nexport type DetachOperatorDeps = {\n credentialsService: TillioCredentialsService\n scope: IntegrationScope\n}\n\nexport type DetachOperatorOptions = {\n /**\n * Drop the local record even though the token could not be revoked. The caller has to\n * decide this explicitly, because the token stays live on Tillio's side afterwards.\n */\n force?: boolean\n}\n\nexport async function detachOperator(\n deps: DetachOperatorDeps,\n operatorId: string,\n options: DetachOperatorOptions = {},\n): Promise<{ ok: boolean; detached: boolean; revoked: boolean }> {\n const blob = await readOperatorsBlob(deps.credentialsService, deps.scope)\n const operator = blob.operators.find((entry) => entry.id === operatorId)\n if (!operator) return { ok: true, detached: false, revoked: false }\n\n const environment = await resolveEnvironment(deps.credentialsService, deps.scope)\n let revoked = false\n if (!environment) {\n if (!options.force) throw new TillioRevocationFailedError(true)\n } else {\n try {\n const client = createTillioClient(environment)\n await client.deleteConfig(operator.plugin, operator.token, operator.tenantDomain)\n revoked = true\n } catch (err) {\n logger.error('could not revoke the Tillio operator token', {\n operatorId,\n status: err instanceof TillioApiError ? err.status : undefined,\n detail: err instanceof TillioApiError ? err.detail : undefined,\n err,\n })\n if (!options.force) {\n throw new TillioRevocationFailedError(false)\n }\n }\n }\n\n const remaining = blob.operators.filter((entry) => entry.id !== operatorId)\n await saveOperatorsBlob(deps.credentialsService, deps.scope, {\n operators: remaining,\n defaultOperatorId: blob.defaultOperatorId === operatorId\n ? (remaining[0]?.id ?? null)\n : blob.defaultOperatorId,\n })\n\n return { ok: true, detached: true, revoked }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { parseBooleanToken } from "@open-mercato/shared/lib/boolean";
|
|
2
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
3
|
+
import { TILLIO_INTEGRATION_ID } from "../integration.js";
|
|
4
|
+
import { environmentSchema } from "./environment.js";
|
|
5
|
+
import { attachOperator, detachOperator, TillioOperatorLimitError } from "./operators.js";
|
|
6
|
+
import { readOperatorsBlob } from "./operators-store.js";
|
|
7
|
+
const logger = createLogger("tillio").child({ component: "preset" });
|
|
8
|
+
const TILLIO_ENV_VARS = {
|
|
9
|
+
apiUrl: "OM_INTEGRATION_TILLIO_API_URL",
|
|
10
|
+
apiKey: "OM_INTEGRATION_TILLIO_API_KEY",
|
|
11
|
+
timeZone: "OM_INTEGRATION_TILLIO_TIMEZONE",
|
|
12
|
+
ringostatKey: "OM_INTEGRATION_TILLIO_RINGOSTAT_KEY",
|
|
13
|
+
force: "OM_INTEGRATION_TILLIO_FORCE_PRECONFIGURE",
|
|
14
|
+
replaceOperator: "OM_INTEGRATION_TILLIO_REPLACE_OPERATOR"
|
|
15
|
+
};
|
|
16
|
+
function readValue(env, key) {
|
|
17
|
+
const value = env[key]?.trim();
|
|
18
|
+
return value ? value : void 0;
|
|
19
|
+
}
|
|
20
|
+
function readTillioEnvPreset(env = process.env) {
|
|
21
|
+
const apiUrl = readValue(env, TILLIO_ENV_VARS.apiUrl);
|
|
22
|
+
const apiKey = readValue(env, TILLIO_ENV_VARS.apiKey);
|
|
23
|
+
if (!apiUrl && !apiKey) return { status: "absent" };
|
|
24
|
+
const missing = [];
|
|
25
|
+
if (!apiUrl) missing.push(TILLIO_ENV_VARS.apiUrl);
|
|
26
|
+
if (!apiKey) missing.push(TILLIO_ENV_VARS.apiKey);
|
|
27
|
+
if (missing.length) return { status: "incomplete", missing };
|
|
28
|
+
return {
|
|
29
|
+
status: "ready",
|
|
30
|
+
credentials: environmentSchema.parse({ apiUrl, apiKey, timeZone: readValue(env, TILLIO_ENV_VARS.timeZone) }),
|
|
31
|
+
ringostatKey: readValue(env, TILLIO_ENV_VARS.ringostatKey) ?? null,
|
|
32
|
+
force: parseBooleanToken(readValue(env, TILLIO_ENV_VARS.force)) ?? false,
|
|
33
|
+
replaceOperator: parseBooleanToken(readValue(env, TILLIO_ENV_VARS.replaceOperator)) ?? false
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function pointsAtAnotherEnvironment(stored, next) {
|
|
37
|
+
const parsed = environmentSchema.safeParse(stored ?? {});
|
|
38
|
+
if (!parsed.success) return false;
|
|
39
|
+
return parsed.data.apiUrl !== next.apiUrl || parsed.data.apiKey !== next.apiKey;
|
|
40
|
+
}
|
|
41
|
+
async function attachOperatorFromPreset(params) {
|
|
42
|
+
if (!params.appUrl.trim()) {
|
|
43
|
+
logger.warn("cannot attach the operator without APP_URL");
|
|
44
|
+
return "failed";
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await attachOperator(
|
|
48
|
+
{
|
|
49
|
+
credentialsService: params.credentialsService,
|
|
50
|
+
scope: params.scope,
|
|
51
|
+
appUrl: params.appUrl,
|
|
52
|
+
withLock: params.withLock
|
|
53
|
+
},
|
|
54
|
+
{ plugin: "Ringostat", config: { key: params.ringostatKey } }
|
|
55
|
+
);
|
|
56
|
+
return "attached";
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err instanceof TillioOperatorLimitError) return "kept";
|
|
59
|
+
logger.warn("failed to attach the operator from the env preset", { err });
|
|
60
|
+
return "failed";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function applyTillioEnvPreset(params) {
|
|
64
|
+
const preset = readTillioEnvPreset(params.env);
|
|
65
|
+
if (preset.status === "absent") {
|
|
66
|
+
return { status: "skipped", reason: "No Tillio preset environment variables were provided." };
|
|
67
|
+
}
|
|
68
|
+
if (preset.status === "incomplete") {
|
|
69
|
+
const reason = `Incomplete Tillio env preset; missing ${preset.missing.join(", ")}.`;
|
|
70
|
+
await params.integrationLogService?.scoped(TILLIO_INTEGRATION_ID, params.scope).warn(reason).catch(() => void 0);
|
|
71
|
+
return { status: "skipped", reason };
|
|
72
|
+
}
|
|
73
|
+
const force = params.force ?? preset.force;
|
|
74
|
+
const existing = await params.credentialsService.getRaw(TILLIO_INTEGRATION_ID, params.scope);
|
|
75
|
+
const credentialsAction = existing && !force ? "kept" : "saved";
|
|
76
|
+
if (credentialsAction === "saved" && pointsAtAnotherEnvironment(existing, preset.credentials)) {
|
|
77
|
+
const attached = (await readOperatorsBlob(params.credentialsService, params.scope)).operators[0] ?? null;
|
|
78
|
+
if (attached) {
|
|
79
|
+
if (!preset.ringostatKey) {
|
|
80
|
+
return {
|
|
81
|
+
status: "blocked",
|
|
82
|
+
reason: `The attached operator belongs to the current environment. Set ${TILLIO_ENV_VARS.ringostatKey} so it can be reattached to the new one.`
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const approved = preset.replaceOperator || (params.confirmOperatorReplacement ? await params.confirmOperatorReplacement(attached) : false);
|
|
86
|
+
if (!approved) {
|
|
87
|
+
return {
|
|
88
|
+
status: "blocked",
|
|
89
|
+
reason: `Switching environments would strand the attached operator. Confirm the replacement or set ${TILLIO_ENV_VARS.replaceOperator}.`
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
await detachOperator({ credentialsService: params.credentialsService, scope: params.scope }, attached.id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (credentialsAction === "saved") {
|
|
96
|
+
await params.credentialsService.save(TILLIO_INTEGRATION_ID, { ...preset.credentials }, params.scope);
|
|
97
|
+
}
|
|
98
|
+
await params.integrationStateService.upsert(TILLIO_INTEGRATION_ID, { isEnabled: true }, params.scope);
|
|
99
|
+
const health = await params.integrationHealthService.runHealthCheck(TILLIO_INTEGRATION_ID, params.scope);
|
|
100
|
+
let operator = "not-requested";
|
|
101
|
+
if (preset.ringostatKey) {
|
|
102
|
+
operator = health.status === "healthy" ? await attachOperatorFromPreset({
|
|
103
|
+
credentialsService: params.credentialsService,
|
|
104
|
+
scope: params.scope,
|
|
105
|
+
appUrl: params.appUrl ?? process.env.APP_URL ?? "",
|
|
106
|
+
ringostatKey: preset.ringostatKey,
|
|
107
|
+
withLock: params.withLock ?? ((run) => run())
|
|
108
|
+
}) : "failed";
|
|
109
|
+
}
|
|
110
|
+
await params.integrationLogService?.scoped(TILLIO_INTEGRATION_ID, params.scope).info("Tillio was preconfigured from environment variables.", {
|
|
111
|
+
credentialsAction,
|
|
112
|
+
health: health.status,
|
|
113
|
+
operator
|
|
114
|
+
}).catch(() => void 0);
|
|
115
|
+
return { status: "applied", credentialsAction, health: health.status, operator };
|
|
116
|
+
}
|
|
117
|
+
export {
|
|
118
|
+
TILLIO_ENV_VARS,
|
|
119
|
+
applyTillioEnvPreset,
|
|
120
|
+
readTillioEnvPreset
|
|
121
|
+
};
|
|
122
|
+
//# sourceMappingURL=preset.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/tillio/lib/preset.ts"],
|
|
4
|
+
"sourcesContent": ["import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'\nimport { TILLIO_INTEGRATION_ID } from '../integration'\nimport { environmentSchema, type TillioEnvironment } from './environment'\nimport { attachOperator, detachOperator, TillioOperatorLimitError } from './operators'\nimport type { TillioLockRunner } from './locking'\nimport { readOperatorsBlob, type TillioCredentialsService, type TillioOperatorRecord } from './operators-store'\n\nconst logger = createLogger('tillio').child({ component: 'preset' })\n\nexport const TILLIO_ENV_VARS = {\n apiUrl: 'OM_INTEGRATION_TILLIO_API_URL',\n apiKey: 'OM_INTEGRATION_TILLIO_API_KEY',\n timeZone: 'OM_INTEGRATION_TILLIO_TIMEZONE',\n ringostatKey: 'OM_INTEGRATION_TILLIO_RINGOSTAT_KEY',\n force: 'OM_INTEGRATION_TILLIO_FORCE_PRECONFIGURE',\n replaceOperator: 'OM_INTEGRATION_TILLIO_REPLACE_OPERATOR',\n} as const\n\nexport type TillioEnvPreset =\n | { status: 'absent' }\n | { status: 'incomplete'; missing: string[] }\n | {\n status: 'ready'\n credentials: TillioEnvironment\n ringostatKey: string | null\n force: boolean\n replaceOperator: boolean\n }\n\nexport type TillioPresetOutcome =\n | { status: 'skipped'; reason: string }\n | { status: 'blocked'; reason: string }\n | {\n status: 'applied'\n credentialsAction: 'saved' | 'kept'\n health: string\n operator: 'attached' | 'kept' | 'not-requested' | 'failed'\n }\n\ntype IntegrationStateServiceLike = {\n upsert: (\n integrationId: string,\n input: { isEnabled?: boolean },\n scope: IntegrationScope,\n ) => Promise<unknown>\n}\n\ntype IntegrationHealthServiceLike = {\n runHealthCheck: (integrationId: string, scope: IntegrationScope) => Promise<{ status: string; message?: string }>\n}\n\ntype IntegrationLogServiceLike = {\n scoped: (\n integrationId: string,\n scope: IntegrationScope,\n ) => {\n info: (message: string, fields?: Record<string, unknown>) => Promise<unknown>\n warn: (message: string, fields?: Record<string, unknown>) => Promise<unknown>\n }\n}\n\nexport type ApplyTillioEnvPresetParams = {\n credentialsService: TillioCredentialsService\n integrationStateService: IntegrationStateServiceLike\n integrationHealthService: IntegrationHealthServiceLike\n scope: IntegrationScope\n force?: boolean\n env?: NodeJS.ProcessEnv\n appUrl?: string\n integrationLogService?: IntegrationLogServiceLike\n // Supplied by the CLI, where somebody is at the terminal to answer. Tenant bootstrap passes\n // nothing, so the swap is refused unless the deployment declared it through the env var.\n confirmOperatorReplacement?: (operator: TillioOperatorRecord) => Promise<boolean>\n /**\n * Serializes the operator write against a concurrent attach from the admin UI. Optional\n * because a bootstrap that runs before the app serves traffic has nothing to contend with;\n * both shipped callers pass a real one.\n */\n withLock?: TillioLockRunner\n}\n\nfunction readValue(env: NodeJS.ProcessEnv, key: string): string | undefined {\n const value = env[key]?.trim()\n return value ? value : undefined\n}\n\nexport function readTillioEnvPreset(env: NodeJS.ProcessEnv = process.env): TillioEnvPreset {\n const apiUrl = readValue(env, TILLIO_ENV_VARS.apiUrl)\n const apiKey = readValue(env, TILLIO_ENV_VARS.apiKey)\n if (!apiUrl && !apiKey) return { status: 'absent' }\n\n const missing: string[] = []\n if (!apiUrl) missing.push(TILLIO_ENV_VARS.apiUrl)\n if (!apiKey) missing.push(TILLIO_ENV_VARS.apiKey)\n // Reported rather than thrown: a half-set preset must not take tenant bootstrap down with it.\n if (missing.length) return { status: 'incomplete', missing }\n\n return {\n status: 'ready',\n credentials: environmentSchema.parse({ apiUrl, apiKey, timeZone: readValue(env, TILLIO_ENV_VARS.timeZone) }),\n ringostatKey: readValue(env, TILLIO_ENV_VARS.ringostatKey) ?? null,\n force: parseBooleanToken(readValue(env, TILLIO_ENV_VARS.force)) ?? false,\n replaceOperator: parseBooleanToken(readValue(env, TILLIO_ENV_VARS.replaceOperator)) ?? false,\n }\n}\n\nfunction pointsAtAnotherEnvironment(\n stored: Record<string, unknown> | null,\n next: TillioEnvironment,\n): boolean {\n const parsed = environmentSchema.safeParse(stored ?? {})\n if (!parsed.success) return false\n return parsed.data.apiUrl !== next.apiUrl || parsed.data.apiKey !== next.apiKey\n}\n\nasync function attachOperatorFromPreset(params: {\n credentialsService: TillioCredentialsService\n scope: IntegrationScope\n appUrl: string\n ringostatKey: string\n withLock: TillioLockRunner\n}): Promise<'attached' | 'kept' | 'failed'> {\n if (!params.appUrl.trim()) {\n logger.warn('cannot attach the operator without APP_URL')\n return 'failed'\n }\n\n try {\n await attachOperator(\n {\n credentialsService: params.credentialsService,\n scope: params.scope,\n appUrl: params.appUrl,\n withLock: params.withLock,\n },\n { plugin: 'Ringostat', config: { key: params.ringostatKey } },\n )\n return 'attached'\n } catch (err) {\n // The slot is taken, which is the expected outcome of a rerun.\n if (err instanceof TillioOperatorLimitError) return 'kept'\n logger.warn('failed to attach the operator from the env preset', { err })\n return 'failed'\n }\n}\n\n// Every step goes through the service the admin UI uses for the same action, so an env-driven\n// bootstrap and a hand-configured one converge on identical stored state, and anything the preset\n// wrote stays editable in the UI afterwards.\nexport async function applyTillioEnvPreset(params: ApplyTillioEnvPresetParams): Promise<TillioPresetOutcome> {\n const preset = readTillioEnvPreset(params.env)\n\n if (preset.status === 'absent') {\n return { status: 'skipped', reason: 'No Tillio preset environment variables were provided.' }\n }\n if (preset.status === 'incomplete') {\n const reason = `Incomplete Tillio env preset; missing ${preset.missing.join(', ')}.`\n await params.integrationLogService?.scoped(TILLIO_INTEGRATION_ID, params.scope).warn(reason).catch(() => undefined)\n return { status: 'skipped', reason }\n }\n\n const force = params.force ?? preset.force\n const existing = await params.credentialsService.getRaw(TILLIO_INTEGRATION_ID, params.scope)\n // Overwriting silently would undo a rotation somebody performed in the UI.\n const credentialsAction = existing && !force ? 'kept' : 'saved'\n\n if (credentialsAction === 'saved' && pointsAtAnotherEnvironment(existing, preset.credentials)) {\n const attached = (await readOperatorsBlob(params.credentialsService, params.scope)).operators[0] ?? null\n if (attached) {\n if (!preset.ringostatKey) {\n return {\n status: 'blocked',\n reason: `The attached operator belongs to the current environment. Set ${TILLIO_ENV_VARS.ringostatKey} so it can be reattached to the new one.`,\n }\n }\n\n const approved = preset.replaceOperator\n || (params.confirmOperatorReplacement ? await params.confirmOperatorReplacement(attached) : false)\n if (!approved) {\n return {\n status: 'blocked',\n reason: `Switching environments would strand the attached operator. Confirm the replacement or set ${TILLIO_ENV_VARS.replaceOperator}.`,\n }\n }\n\n // Revoked while the credentials that minted its token are still stored; afterwards the new\n // environment would reject the token and the record could only be dropped by force.\n await detachOperator({ credentialsService: params.credentialsService, scope: params.scope }, attached.id)\n }\n }\n\n if (credentialsAction === 'saved') {\n await params.credentialsService.save(TILLIO_INTEGRATION_ID, { ...preset.credentials }, params.scope)\n }\n\n await params.integrationStateService.upsert(TILLIO_INTEGRATION_ID, { isEnabled: true }, params.scope)\n\n // Also mints the tenant identity the operator token is bound to, so it has to run before attaching.\n const health = await params.integrationHealthService.runHealthCheck(TILLIO_INTEGRATION_ID, params.scope)\n\n let operator: 'attached' | 'kept' | 'not-requested' | 'failed' = 'not-requested'\n if (preset.ringostatKey) {\n operator = health.status === 'healthy'\n ? await attachOperatorFromPreset({\n credentialsService: params.credentialsService,\n scope: params.scope,\n appUrl: params.appUrl ?? process.env.APP_URL ?? '',\n ringostatKey: preset.ringostatKey,\n withLock: params.withLock ?? ((run) => run()),\n })\n : 'failed'\n }\n\n await params.integrationLogService\n ?.scoped(TILLIO_INTEGRATION_ID, params.scope)\n .info('Tillio was preconfigured from environment variables.', {\n credentialsAction,\n health: health.status,\n operator,\n })\n .catch(() => undefined)\n\n return { status: 'applied', credentialsAction, health: health.status, operator }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|