/* * **연결을 배포 사이로 옮기는 문 셋** — 몸통은 `connection-portability.ts` 의 순수 함수들이다. * * ── 무엇을 옮기나 (사용자와 맞춘 개념, 2026-09-05) ───────────────────────── * **옮기는 것은 「붙는 법」이지 「붙은 결과」가 아니다.** * * 옮긴다 연결 선언 — 주소 · 도메인 · 라인 크기 · 자재 이름공간 * 안 옮긴다 마스터 원본에서 다시 읽으면 된다 * 안 옮긴다 사실(저널) 원본이 다시 보내면 된다 * 안 옮긴다 잰 값 그 서버에서 다시 세면 된다 * 안 옮긴다 비밀값 그 환경의 것이지 셋업이 아니다 * * **패키지는 정본이 아니라 운반 수단이다.** 정본은 언제나 그 배포의 DB 이고, 패키지는 쓰고 버린다. * * ── 주체는 받는 쪽이다 ───────────────────────────────────────────────────── * 운영이 개발에서 **당겨온다.** 개발이 운영으로 밀면 개발 환경에 운영 자격이 살고, 그 통로는 실수로도 * 열린다. 그래서 `pullTwinConnectionsFrom` 이 본래 원하던 문이고, 나머지 둘은 저쪽에 못 닿을 때의 * 길이다. * * ── 쓰기 전에 계획을 보인다 ──────────────────────────────────────────────── * `planImport` 가 새로/갱신/건너뜀과 **사람이 채울 비밀값**을 먼저 낸다. 부딪힘(두 연결이 같은 공장에 * 조치를 내는 것)이 하나라도 있으면 **아무것도 쓰지 않는다** — 반만 적용된 연결 설정은 어느 쪽이 * 참인지 아무도 모른다. */ import { Arg, Ctx, Directive, Mutation, Query, Resolver } from 'type-graphql' import { ScalarObject, getRepository } from '@things-factory/shell' import { TwinReference } from './twin-reference.js' import { exportConnections, intakeCollisions, planImport, sayPlan, secretEnvName } from './connection-portability.js' /** 그 환경이 이 비밀값을 줄 수 있나 — **값은 읽지 않는다.** 이름의 존재만 본다. */ function hasSecret(source: string, key: string): boolean { const name = secretEnvName(source, key) const v = process.env[name] return typeof v === 'string' && v.trim().length > 0 } @Resolver() export class ConnectionPortabilityResolver { /** * 지금 연결을 패키지로 낸다 — **비밀값은 안 나간다.** * * 이름 규칙으로 걸러서 **새 비밀 칸도 기본으로 안 나간다**(`SECRET_KEYS`). 나가지 않은 것은 * 패키지가 이름으로 말하고, 받는 쪽이 그 이름을 사람에게 보인다. */ /* * **읽기인데 쓰기와 같은 문을 쓴다.** 이 패키지는 연결 주소·도메인과 **비밀 칸의 이름**까지 낸다 — * 그 이름만으로도 그 배포가 무엇에 붙어 있는지 드러난다. 권한 가드가 「쓰기는 GATE 하나」로 * 통일해 두었고, 이 문은 읽기지만 그 무게가 쓰기 쪽이다(ADR-0027). */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Query(returns => ScalarObject, { description: 'Export this deployment’s twin connection declarations as a portable bundle. Secrets are never included — the bundle names them so the receiving side can ask a human.' }) async twinConnectionBundle( @Ctx() context: ResolverContext, @Arg('note', { nullable: true, description: 'Free note carried in the bundle (why this export was made).' }) note?: string ): Promise { const domain = context.state.domain const rows = await getRepository(TwinReference).find({ where: { domain: { id: domain.id } } }) return exportConnections(rows as any, { at: new Date().toISOString(), domain: domain.subdomain ?? domain.name, ...(note ? { note } : {}) }) } /** * 패키지를 받아 적용한다 — **`dryRun` 이 기본이다.** * * 쓰기가 기본이면 「무엇이 바뀌나」를 보기 위해 쓰게 된다. 계획을 먼저 보고 사람이 정하는 것이 * 이 문의 요점이고, 그래서 적용은 명시해야 일어난다. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Apply a connection bundle. Returns the plan (new / updated / skipped, secrets a human must supply, collisions). Defaults to dryRun — pass apply:true to write.' }) async importTwinConnections( @Arg('bundle', type => ScalarObject) bundle: any, @Ctx() context: ResolverContext, @Arg('apply', { nullable: true, description: 'Write the plan. Omit or false to only see what would happen.' }) apply?: boolean ): Promise { const domainId = context.state.domain.id const repo = getRepository(TwinReference) const existing = await repo.find({ where: { domain: { id: domainId } } }) const plan = planImport(bundle, existing as any, hasSecret) const said = sayPlan(plan) /* * 부딪힘이 있으면 **아무것도 쓰지 않는다.** 두 연결이 같은 공장에 조치를 내면 어느 쪽이 그 라인의 * 주인인지 정할 수 없고, 반만 적용하면 그 물음이 조용히 남는다. */ if (plan.collisions.length || plan.errors.length) { return { ok: false, applied: 0, plan, said, refused: plan.collisions.length ? 'intake-collision' : 'bundle-error' } } if (!apply) return { ok: true, applied: 0, plan, said, dryRun: true } let applied = 0 for (const a of plan.actions) { /* 건너뛴 것은 이유가 계획에 남아 있다 — 여기서 다시 판정하지 않는다. */ if (a.action === 'skip') continue const c = a.connection /* * **비밀값을 빈 값으로 안 채운다.** 패키지에 비밀값이 없고, `planImport` 가 그 이름을 * `missingSecrets`·`needsHuman` 으로 이미 냈다. 여기서 그 칸을 `undefined` 로 쓰면 사람이 * 화면에서 넣어 둔 값을 파일 하나가 지운다. * * 그래서 갱신은 **선언 칸만** 건드린다. 이미 있는 연결의 `connectionConfig` 는 패키지가 든 * 것만 덮어쓰고 나머지 키는 그대로 남긴다. */ const found = await repo.findOne({ where: { domain: { id: domainId }, source: c.source } }) const declared = { ...(c.system ? { system: c.system } : {}), ...(c.siteName ? { siteName: c.siteName } : {}), adapterType: c.adapterType, ...(c.scope ? { scopeSpec: c.scope } : {}) } as any if (found) { const merged = { ...((found as any).connectionConfig ?? {}), ...(c.config ?? {}) } await repo.update({ id: (found as any).id }, { ...declared, connectionConfig: merged } as any) } else { await repo.save(repo.create({ source: c.source, ...declared, connectionConfig: c.config ?? {}, domain: { id: domainId } as any })) } applied++ } return { ok: true, applied, plan, said } } /** * 저쪽 배포에서 당겨와 적용한다 — **본래 원한 문.** * * 받는 쪽이 주체다. 저쪽의 `twinConnectionBundle` 을 불러 이 배포에 적용한다 — 위 두 문을 잇는 * 얇은 층이고, 판정과 쓰기는 `importTwinConnections` 와 같은 규칙을 쓴다. * * **자격은 부르는 사람이 준다.** 이 서버가 저쪽 자격을 들고 있으면 그 통로가 상시로 열려 있게 되고, * 그것은 옮기는 일에 필요한 것보다 넓다. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Pull connection declarations from another deployment and apply them here. The caller supplies that deployment’s URL and token — this server does not keep them.' }) async pullTwinConnectionsFrom( @Arg('endpoint', { description: 'That deployment’s GraphQL endpoint.' }) endpoint: string, @Ctx() context: ResolverContext, @Arg('token', { nullable: true, description: 'Bearer token for that deployment. Not stored.' }) token?: string, @Arg('apply', { nullable: true }) apply?: boolean ): Promise { let bundle: any try { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify({ query: '{ twinConnectionBundle }' }) }) const json: any = await res.json() /* 저쪽이 오류를 냈으면 그 말을 그대로 올린다 — 여기서 다시 지으면 원인이 흐려진다. */ if (json?.errors?.length) return { ok: false, applied: 0, refused: 'remote-error', remote: json.errors.map((e: any) => e?.message) } bundle = json?.data?.twinConnectionBundle } catch (e: any) { return { ok: false, applied: 0, refused: 'unreachable', remote: [String(e?.message ?? e)] } } if (!bundle) return { ok: false, applied: 0, refused: 'empty-bundle' } return this.importTwinConnections(bundle, context, apply) } } /** 이 리졸버가 다루는 부딪힘 판정 — 화면이 같은 함수를 쓸 수 있게 다시 내보낸다. */ export { intakeCollisions }