/* * **연결을 내보내고 다시 들여옵니다** — 실제 셋업을 파일로 보관하고 되세웁니다. * * ── 이것은 데모가 아니라 제품 기능입니다 (2026-09-05) ─────────────────────── * 데모 시드(`seed-demo-twins.ts`)와 **다른 것**입니다. 그쪽은 내장 가상 마스터로 보여 줄 트윈을 * 만들고 `TWIN_PROVISION_DEMO` 뒤에 숨습니다. 이것은 **실제로 붙인 연결을 옮기는 길**이라 * 데모 스위치 뒤에 두지 않습니다 — 백업·이관·복구가 데모일 수 없습니다. * * 그래서 특정 공장을 제품 코드에 적지 않습니다. 적으면 배포마다 남의 공장이 하나씩 따라가고, * 데모 시드 머리말이 적어 둔 「프로덕션 배포는 **트윈 0** 에서 시작한다」가 깨집니다. 무엇을 세울지는 * **파일이 말하고 코드는 그 파일을 읽을 줄만 압니다.** * * ── 왜 필요했나 ──────────────────────────────────────────────────────────── * 실 MES 두 공장을 트윈에 세웠는데(`mes-line-a` · `mes-miratek`) **그 셋업이 한 데이터베이스 * 안에만 있었습니다.** 잃으면 다시 손으로 만들어야 하고, 다른 배포에 옮길 길도 없었습니다. * * ── 담는 것과 안 담는 것 ──────────────────────────────────────────────────── * * 담는다 연결 선언 — 주소 · 도메인 · 사이트 · 치수 · 이름공간 · 받는 자리 * 안 담는다 비밀값 — 토큰 · 웹훅 서명값 · 받는 쪽 비밀값 * 안 담는다 사실(저널) — 원본이 다시 보내면 된다. 담으면 **가짜 과거가 배포마다 복제된다** * 안 담는다 마스터에서 온 트윈 모델 — 연결이 있으면 재동기화가 다시 만든다 * * **비밀값은 이름만 적습니다.** 어느 칸이 채워져 있었는지는 알려 주되 값은 안 나갑니다 — 파일은 * 주고받는 물건이고, 한 번 나간 비밀값은 되돌릴 수 없습니다. 들여올 때 「이 셋이 없다」고 말하고 * 사람이 연결 화면에서 채웁니다. * * ── 왜 사실을 안 담나 ─────────────────────────────────────────────────────── * 저널은 되풀이 읽히고 미러로 복제되고 구독으로 방송됩니다. 내보낸 사실을 다른 배포에 들여오면 * **일어나지 않은 일이 그 공장의 지난 기록에 섭니다.** 커서만 옮겨 두면 원본이 그 뒤부터 다시 * 보내 주므로, 사실은 원본에서 오는 것이 맞습니다. */ /** 값이 아니라 **채워져 있었다는 사실**만 나가는 칸들. */ export const SECRET_KEYS = ['token', 'hookSecret', 'intakeSecret', 'password', 'apiKey'] as const export interface PortableConnection { source: string system?: string siteName?: string adapterType: string /** 비밀값을 뺀 연결 설정. */ config: Record /** 내보낼 때 채워져 있던 비밀 칸 이름 — 값은 없습니다. */ secretsRequired: string[] /** 이 연결이 무엇을 세우나. */ scope?: unknown } export interface ConnectionBundle { kind: 'operato-twin/connections' version: 1 exportedAt: string /** 어디서 내보냈나 — 사람이 나중에 「이게 어느 배포 것인가」를 물을 때 답합니다. */ exportedFrom?: { domain?: string; note?: string } connections: PortableConnection[] } /** * 무엇을 세우나 **선언만** 남깁니다 — 지난 동기화의 **측정값은 뺍니다.** * * `scopeSpec` 에는 `produced`(선언) 옆에 `assessments`(그때 몇 개가 섰나) · `lastFailures` 가 함께 * 삽니다. 그것까지 내보내면 새 배포가 **다른 기계에서 잰 수**를 자기 것으로 보입니다 — 「설비 42」가 * 아무것도 안 세운 배포의 화면에 서고, 그 수가 어디서 왔는지 아무도 모릅니다. * * 재는 것은 그 배포에서 다시 재야 합니다. 오늘 「설정을 고쳤다」와 「모델이 다시 섰다」를 가른 것과 * 같은 구별입니다. */ function producedOf(scopeSpec: any): { produced: unknown } | undefined { const produced = scopeSpec?.produced return Array.isArray(produced) && produced.length ? { produced } : undefined } const isSecret = (key: string): boolean => (SECRET_KEYS as readonly string[]).includes(key) || /secret|token|password|apikey/i.test(key) /** * 연결 행들을 옮길 수 있는 모양으로 — **비밀값을 빼고**. * * 빼는 판정을 이름으로 합니다. 목록으로만 하면 커넥터가 새 비밀 칸을 더할 때 그 값이 조용히 * 파일로 나갑니다 — 이름 규칙을 함께 두어 **새 칸이 기본으로 안 나가게** 합니다. */ export function exportConnections( rows: readonly { source: string; system?: string; siteName?: string; adapterType: string; connectionConfig?: any; scopeSpec?: any }[], meta: { at: string; domain?: string; note?: string } ): ConnectionBundle { return { kind: 'operato-twin/connections', version: 1, exportedAt: meta.at, ...(meta.domain || meta.note ? { exportedFrom: { ...(meta.domain ? { domain: meta.domain } : {}), ...(meta.note ? { note: meta.note } : {}) } } : {}), connections: rows.map(r => { const cfg = (r.connectionConfig ?? {}) as Record const kept: Record = {} const secrets: string[] = [] for (const [k, v] of Object.entries(cfg)) { if (isSecret(k)) { /* 비어 있던 칸은 적지 않습니다 — 「채워야 한다」와 「이 커넥터에 그런 칸이 있다」는 다릅니다. */ if (String(v ?? '').trim()) secrets.push(k) continue } kept[k] = v } return { source: r.source, ...(r.system ? { system: r.system } : {}), ...(r.siteName ? { siteName: r.siteName } : {}), adapterType: r.adapterType, config: kept, secretsRequired: secrets.sort(), ...(producedOf(r.scopeSpec) ? { scope: producedOf(r.scopeSpec) } : {}) } }) } } export type ImportAction = | { action: 'create'; connection: PortableConnection; missingSecrets: string[] } | { action: 'update'; connection: PortableConnection; missingSecrets: string[]; keptSecrets: string[] } | { action: 'skip'; source: string; why: string } export interface ImportPlan { actions: ImportAction[] /** 사람이 연결 화면에서 채워야 하는 것 — 이것이 비어야 「시드만으로 끝」입니다. */ /** * 사람이 환경에 넣어야 하는 비밀값. * * `env` 를 함께 냅니다 — **그 이름을 사람이 손으로 적기 때문**입니다. 화면이 이름을 따로 만들면 * 규칙이 두 곳에 살고, 갈리는 날 「넣었는데 안 읽힌다」가 됩니다. 실제로 났습니다(2026-09-06): * 화면이 `TWIN_SECRET__MES-MIRATEK__HOOKSECRET` 을 알려 줬는데 서버가 읽는 이름은 * `TWIN_SECRET__MES_MIRATEK__HOOK_SECRET` 이었습니다. 하이픈과 낙타 경계 둘 다 달랐습니다. * * `keys` 와 순서가 같습니다 — `keys[i]` 의 이름이 `env[i]` 입니다. */ needsHuman: { source: string; keys: string[]; env: string[] }[] /** 들여온 **뒤에** 두 연결이 같은 곳에 조치를 내게 되나 — 있으면 들여오면 안 됩니다. */ collisions: { intake: string; sources: string[] }[] errors: string[] } /** * 들여오기 **계획**을 먼저 냅니다 — 쓰기 전에 무엇이 일어날지 사람이 봅니다. * * ── 이미 있는 연결을 덮지 않습니다 ────────────────────────────────────────── * 같은 이름이 이미 있으면 **선언만 갱신하고 비밀값은 그대로 둡니다.** 사람이 화면에서 넣은 값을 * 파일이 지우면, 파일 하나 들여왔다가 도는 연결이 멈춥니다. 데모 시드도 같은 규율을 적어 두었습니다 * (「옆 설정을 지우지 않는다 — 시드는 자기가 아는 것만 채운다」). * * ── 없는 비밀값을 지어내지 않습니다 ───────────────────────────────────────── * 빈 값으로 채워 두면 연결이 서고 **동기화할 때 처음 실패합니다.** 그때는 무엇이 없어서인지 화면에서 * 멉니다. 여기서 이름을 대고 멈추는 것이 낫습니다. */ export function planImport( bundle: unknown, existing: readonly { source: string; connectionConfig?: any }[], /** 지금 환경이 줄 수 있는 비밀값 이름들 — 값은 안 받습니다(이 함수는 값을 만지지 않습니다). */ availableSecrets: (source: string, key: string) => boolean = () => false ): ImportPlan { const errors: string[] = [] const b = bundle as ConnectionBundle if (!b || typeof b !== 'object') return { actions: [], needsHuman: [], collisions: [], errors: ['들여올 것이 객체가 아닙니다'] } if (b.kind !== 'operato-twin/connections') { return { actions: [], needsHuman: [], collisions: [], errors: [`이 파일은 연결 설정 파일가 아닙니다(kind: ${JSON.stringify((b as any).kind ?? null)})`] } } if (b.version !== 1) errors.push(`모르는 버전입니다(version: ${JSON.stringify((b as any).version)}) — 읽을 수 있는 것만 읽습니다`) if (!Array.isArray(b.connections)) return { actions: [], needsHuman: [], collisions: [], errors: [...errors, 'connections 가 목록이 아닙니다'] } const have = new Map(existing.map(e => [e.source, e])) const seen = new Set() const actions: ImportAction[] = [] const needsHuman: { source: string; keys: string[]; env: string[] }[] = [] for (const c of b.connections) { const source = String(c?.source ?? '').trim() if (!source) { errors.push('이름 없는 연결이 있습니다 — 건너뜁니다') continue } if (seen.has(source)) { actions.push({ action: 'skip', source, why: '같은 이름이 파일에 두 번 있습니다' }) continue } seen.add(source) if (!String(c?.adapterType ?? '').trim()) { actions.push({ action: 'skip', source, why: '커넥터 종류가 없습니다 — 무엇으로 붙일지 모릅니다' }) continue } const cur = have.get(source) const curCfg = (cur?.connectionConfig ?? {}) as Record const wanted = Array.isArray(c.secretsRequired) ? c.secretsRequired.map(String) : [] const kept = wanted.filter(k => String(curCfg[k] ?? '').trim()) const missing = wanted.filter(k => !kept.includes(k) && !availableSecrets(source, k)) if (missing.length) needsHuman.push({ source, keys: missing, env: missing.map(k => secretEnvName(source, k)) }) actions.push( cur ? { action: 'update', connection: c, missingSecrets: missing, keptSecrets: kept } : { action: 'create', connection: c, missingSecrets: missing } ) } /* * **들여온 뒤의 모습으로 봅니다.** 파일 안에서만 보면 「이미 있는 연결과 부딪히는 것」을 못 * 잡습니다 — 그게 실제로 더 쉽게 나는 쪽입니다(하나는 화면에서, 하나는 파일에서 생깁니다). */ const after = [ /* 행 모양 그대로 넘긴다 — 옮기는 자리는 `intakeCollisions` 안 한 곳이다(ADR-0053 ①). */ ...existing.filter(e => !seen.has(e.source)), ...actions .filter((a): a is Extract => a.action !== 'skip') .map(a => ({ source: a.connection.source, config: a.connection.config })) ] return { actions, needsHuman, collisions: intakeCollisions(after), errors } } /** 사람이 읽는 한 줄 — 화면과 로그가 같은 문장을 쓰게. */ export function sayPlan(plan: ImportPlan): string { const n = (a: ImportAction['action']) => plan.actions.filter(x => x.action === a).length const head = `연결 ${plan.actions.length}개 — 새로 ${n('create')} · 갱신 ${n('update')} · 건너뜀 ${n('skip')}` /* 부딪힘을 먼저 말합니다 — 비밀값이 없는 것은 안 도는 것이고, 부딪히는 것은 **잘못 도는 것**입니다. */ if (plan.collisions.length) { const who = plan.collisions.map(c => `${c.sources.join(' · ')} → ${c.intake}`).join(', ') return `${head}. ⚠ **두 연결이 같은 곳에 조치를 냅니다** — ${who}. 다른 공장의 작업지시가 한 공장에 섭니다` } if (!plan.needsHuman.length) return `${head}. 비밀값은 다 있습니다` const who = plan.needsHuman.map(h => `${h.source}(${h.keys.join(' · ')})`).join(', ') return `${head}. **비밀값을 사람이 넣어야 합니다** — ${who}. 연결 화면에서 채우면 그때부터 돕니다` } /** * 비밀값이 사는 **환경 변수 이름** — `mes-miratek` · `hookSecret` → `TWIN_SECRET__MES_MIRATEK__HOOK_SECRET`. * * 패키지에는 비밀 칸의 이름만 있고 값은 환경에서 옵니다. 그 이름을 **사람이 손으로 적으므로** 규칙이 * 흔들리면 「넣었는데 안 읽힌다」가 됩니다 — 오늘 종일 잡은 「양쪽 다 초록인데 안 간다」의 설정 쪽 * 버전입니다. 그래서 이 규칙을 한 곳에 두고 시험으로 못 박습니다. * * 낙타 이름의 경계를 밑줄로 가릅니다 — 안 그러면 `HOOKSECRET` 과 `HOOK_SECRET` 이 갈려 한쪽만 읽힙니다. */ export function secretEnvName(source: string, key: string): string { const up = (x: string) => x .replace(/([a-z0-9])([A-Z])/g, '$1_$2') .replace(/[^A-Za-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .toUpperCase() return `TWIN_SECRET__${up(source)}__${up(key)}` } /** * **두 연결이 같은 곳에 조치를 내지 않나** — 같으면 다른 공장의 작업지시가 한 공장에 섭니다. * * ── 왜 이 검사가 생겼나 (2026-09-05) ──────────────────────────────────────── * `mes-miratek` 이 서면서 한 커넥터가 처음으로 **공장 둘**을 달았습니다. 조치가 나가는 주소는 * `intakeSiteId` 가 정하는데(`operato-plant.ts:389`), 그 값이 트윈의 사이트 이름을 **이깁니다** — * 트윈에서는 `mes-line-a` 인데 MES 의 도메인은 `system` 이라 그 override 가 필요합니다. * * 그래서 **연결을 베껴 만들고 그 칸만 안 고치면** 두 트윈의 조치가 같은 공장으로 갑니다. 오류가 * 안 납니다 — 저쪽은 정상적인 작업지시로 받고 **실제로 만듭니다.** * * 게이트에 이미 「사이트를 모르면 보내지 않는다」가 있는데(`no-site`), 그 이유가 적혀 있는 문장이 * **「잘못된 공장이 실제로 만든다」** 입니다. 이것은 그 둘째 길입니다 — 사이트를 아는데 **틀린 * 사이트를 압니다.** * * 파일로 연결을 만들 수 있게 되면서 더 쉬워졌습니다: 패키지를 베껴 이름만 바꾸면 받는 자리가 * 그대로 따라옵니다. */ export function intakeCollisions( connections: readonly { source: string adapterType?: string /** 패키지·계획이 쓰는 이름. */ config?: Record | null /** **DB 행이 쓰는 이름** — `TwinReference` 의 칸이다. 둘 다 받는다(ADR-0053 ①). */ connectionConfig?: Record | null }[] ): { intake: string; sources: string[] }[] { const byIntake = new Map() for (const c of connections) { /* * **모양이 둘인 것을 여기서 안다** (ADR-0053 ①, 2026-09-16). * * 패키지는 `config`, `TwinReference` 행은 `connectionConfig` 다. 전에는 `config` 만 읽어서, 행을 * 그대로 넘기면 같은 충돌에도 `[]` 를 냈다 — 오류도 경고도 없이 **저장이 통과하고 가드는 선 채로 * 아무 일도 안 한다.** 부르는 쪽마다 옮기게 두면 다음 소비처가 또 밟는다(이 함수는 화면이 쓰라고 * 리졸버에서 다시 내보내진다). * * 「없다」가 편한 답인 자리라 더 위험하다 — 빨강이 아니라 **조용한 초록**으로 흐른다. */ const cfg = c.config ?? c.connectionConfig ?? {} /* 받는 자리를 정하는 규칙은 커넥터의 것과 같아야 합니다 — 여기서 다시 지으면 두 벌이 됩니다. */ const intake = String(cfg.intakeSiteId ?? cfg.siteId ?? '').trim() /* 조치를 낼 수 없는 연결은 부딪힐 것이 없습니다 — 주소가 없으면 아무 데도 안 갑니다. */ if (!intake || !String(cfg.intakeEndpoint ?? '').trim()) continue const key = `${String(cfg.intakeEndpoint).trim().replace(/\/$/, '')}::${intake}` byIntake.set(key, [...(byIntake.get(key) ?? []), c.source]) } return [...byIntake.entries()] .filter(([, sources]) => sources.length > 1) .map(([intake, sources]) => ({ intake, sources: sources.sort() })) } /** * **화면 저장의 문** — 이 연결을 이 설정으로 저장하면 다른 연결과 받는 사이트가 겹치나 (ADR-0046 ②-1). * * 들여오기(`planImport`)는 `intakeCollisions` 로 거부하는데 화면 저장(`saveTwinReference`)은 부르지 않아서, * 연결을 베껴 이름만 바꾸면 경고 0 으로 두 트윈의 조치가 한 공장에 섰다. 판정은 같은 함수 하나로 한다. * 경고가 아니라 거부이고, 사람이 그 자리에서 고치도록 **이미 그 사이트를 쓰는 연결의 이름**을 싣는다. */ export function refuseIntakeCollision( source: string, config: Record | undefined, others: readonly { source: string; siteName?: string | null; connectionConfig?: Record | null }[] ): { code: 'intake-site-taken'; message: string; params: { site: string; connection: string } } | null { const rest = others.filter(o => o.source !== source) /* `others` 는 DB 행이다 — 옮기지 않고 그대로 넘긴다(ADR-0053 ①). */ const hit = intakeCollisions([...rest, { source, config: config ?? {} }]).find(c => c.sources.includes(source)) if (!hit) return null const connection = rest .filter(o => hit.sources.includes(o.source)) .map(o => o.siteName || o.source) .join(', ') const site = String(config?.intakeSiteId ?? config?.siteId ?? '').trim() return { code: 'intake-site-taken', message: `intake site ${site} is already used by ${connection}`, params: { site, connection } } }