/* * 연결된 시스템이 **밀어 주는** 사실을 받는 자리 — 웹훅. * * 규약(상태 코드 · 비밀값 · 대상 확인)은 `hook-contract.ts` 에 있다. 여기는 그것을 써서 찾고 · 확인하고 · * 커넥터에게 옮기게 하고 · 유입으로 넘기는 순서만 맡는다. * * ── 왜 필요한가 ───────────────────────────────────────────────────────────── * 지금 모든 커넥터가 주기적으로 물어봅니다(폴링). 그 방식은 두 가지를 치릅니다. 연결된 시스템이 값을 * 갱신하는 주기보다 자주 물으면 같은 값을 되풀이 받고, 그쪽 서버에 그만큼 부담을 줍니다. 실제로 * 태양광 발전소에 10초마다 물으면서 10분마다 바뀌는 값을 받아 왔습니다. * * 연결된 시스템이 「바뀌었다」를 말해 줄 수 있으면 그것을 받는 것이 낫습니다. 이 파일이 그 입구입니다. * * ── 규약 ──────────────────────────────────────────────────────────────────── * * 주소 POST /twin/hook/<연결이름>/<트윈> — no /domain/<도메인> prefix: the route sits on the domain-public * router without one (server/routes.ts:31); a prefixed address falls through and answers 405 * 도메인 X-Things-Factory-Domain: <도메인> — the webhook module sends it from the destination's * params.receivingDomain * 인증 연결 설정에 저장한 비밀값(`hookSecret`)을 헤더로 보낸다 * X-Twin-Hook-Secret: <비밀값> 또는 Authorization: Bearer <비밀값> * 본문 연결된 시스템이 정한 모양 그대로. 우리 어휘로 옮기는 것은 커넥터의 일이다 * 응답 아래 §`hookStatus` * * ── 왜 사람 인증(JWT)이 아닌가 ────────────────────────────────────────────── * 밀어 주는 쪽은 사람이 아닙니다. 우리 토큰을 발급받아 갱신하며 관리하라고 요구하면 대부분의 현장에서 * 연동이 서지 않습니다. 그래서 연결마다 비밀값을 두고 그것으로 확인합니다. * * **비밀값이 없는 연결은 훅을 받지 않습니다.** 기본이 거부입니다 — 인증 없는 입구를 열어 두는 것보다 * 훅을 못 쓰는 것이 낫습니다. * * ── 왜 커넥터가 옮기나 (시나리오가 아니라) ────────────────────────────────── * 연결된 시스템의 웹훅은 그쪽이 정한 고정된 모양으로 옵니다. 그 모양을 아는 것은 그 커넥터이고, * 옮기는 판단은 코드여야 테스트로 지킬 수 있습니다. 실제로 지금 커넥터들이 코드로 판단합니다 — * 통신이 끊긴 설비의 값은 보내지 않고, 잰 시각이 나아가지 않은 줄은 거릅니다. 그 판단을 변환식으로 * 옮기면 약해지고 테스트가 없어집니다. * * ── 밀어 주기만으로는 안 된다 ─────────────────────────────────────────────── * 훅은 반드시 놓칩니다 — 우리가 내려가 있을 때, 그쪽이 못 보냈을 때, 네트워크가 끊겼을 때. 그래서 * 훅을 쓰는 연결도 **드문 폴링을 유지합니다.** 폴링의 역할이 「값을 가져오는 것」에서 「놓친 것이 있나 * 확인하는 것」으로 바뀌는 것입니다. 그 주기는 연결 설정이 정합니다. * * ── 같은 것이 두 번 오는 것 ───────────────────────────────────────────────── * 밀어 주는 방식은 재전송이 정상입니다(그쪽이 우리 응답을 못 받으면 다시 보냅니다). 그것은 유입 * 경계가 이미 거릅니다(§`FactDeduper`) — 이 파일이 따로 하지 않습니다. 응답에 몇 건이 중복이었는지 * 함께 알립니다. */ import { WEBHOOK_STATUS, type WebhookResponse } from '@operato/ops-contract' import { twinWarn } from '../../engine/log.js' import { getAdapter, type InboundBatch } from './reference-adapter.js' import { rejectedForCaller, type RejectedForCaller } from './hook-rejected.js' export { rejectedForCaller, type RejectedForCaller } from './hook-rejected.js' import { authorizeHook, producedInstance, pushCursorKey, seqOf, takeInSequence, withSeq, type HookOutcome } from './hook-contract.js' type WebhookLane = 'FACTS' | 'ATTENTIONS' function webhookLaneOf(value: unknown): WebhookLane | undefined { return value === undefined || value === 'FACTS' ? 'FACTS' : value === 'ATTENTIONS' ? 'ATTENTIONS' : undefined } /** * 훅 하나를 처리한다 — 찾고 · 확인하고 · 커넥터에게 옮기게 하고 · 유입으로 넘긴다. * * 유입 자체(`ingest`)는 부르는 쪽이 넘겨 준다: 이 파일이 엔진을 직접 부르면 시험할 때 엔진이 필요해지고, * 그러면 규약을 확인하는 시험을 쓸 수 없다. */ export async function handleHook(args: { domainId: string source: string instanceId: string headers: Record body: unknown /** * 받은 바이트 그대로 — 서명을 확인하려면 이것이 있어야 한다. * * 파싱한 객체를 다시 문자열로 만들면 키 순서와 공백이 달라져 서명이 맞지 않는다. 없으면 서명을 쓰는 * 요청은 거절된다(§`authorizeHook`). */ rawBody?: string /** 서명 창을 재는 기준 시각. 부르는 쪽이 준다 — 여기서 읽으면 시험이 시계에 매인다. */ nowMs?: number /** 레코드를 트윈에 넣는다 — 받은 수와 **떨어진 것들**을 돌려준다. */ ingest: (instanceId: string, records: unknown[], lane: WebhookLane) => IngestResult | Promise /** * 구멍을 봤다 — **부르는 쪽이 무엇을 할지 정한다.** * * 여기서 backfill 을 직접 부르지 않는다. 이 파일은 `ingest`·`store` 를 인자로 받아 가볍게 유지한다 * (그래야 테스트가 엔진 없이 이 판정을 확인할 수 있다). backfill 을 import 하면 그 규율이 깨진다. * * 기다리지 않는 것도 부르는 쪽의 몫이다 — 훅의 응답이 늦으면 보내는 쪽이 되풀이한다. */ onGap?: (scope: string) => void /** * 저장소를 대신 쓰는 자리 — **시험이 재기동을 넣을 수 있게.** * * `ingest` 를 부르는 쪽이 넘겨 주는 것과 같은 이유다(위 §). 커서는 재기동을 넘어 살아야 하는데, 한 * 프로세스 안에서만 확인하면 「적히나」는 보이지만 「다시 세우면 읽히나」는 안 보인다. 그 둘이 갈린 * 결함을 이 저장소가 이미 겪었다. */ store: HookStore }): Promise { const { domainId, source, instanceId, headers, body, rawBody, ingest, onGap } = args const nowMs = args.nowMs ?? Date.now() const { store } = args const ref = await store.load(domainId, source) if (!ref) { return { status: WEBHOOK_STATUS.unknownTarget, body: { ok: false, error: `unknown connection "${source}"` } } } const cfg: any = (ref as any).connectionConfig ?? {} const expected = String(cfg.hookSecret ?? '').trim() if (!expected) { /* 비밀값을 선언하지 않은 연결은 훅을 받지 않는다 — 기본이 거부다. */ return { status: WEBHOOK_STATUS.badSecret, body: { ok: false, error: `connection "${source}" declares no hookSecret — set one in the connection settings before pushing to it` } } } const auth = authorizeHook({ headers, rawBody, secret: expected, nowMs }) if (auth.ok === false) { return { status: WEBHOOK_STATUS.badSecret, body: { ok: false, error: auth.reason } } } const site = producedInstance(ref as any, instanceId) if (!site) { return { status: WEBHOOK_STATUS.unknownTarget, body: { ok: false, error: `twin "${instanceId}" was not created by connection "${source}"` } } } const adapter: any = getAdapter(String((ref as any).adapterType ?? '')) if (typeof adapter?.handleInbound !== 'function') { return { status: WEBHOOK_STATUS.unsupported, body: { ok: false, error: `connector "${(ref as any).adapterType}" cannot take pushed events (no handleInbound)` } } } let batch: InboundBatch try { batch = adapter.handleInbound(cfg, site, body, headers) } catch (e: any) { /* 옮기지 못한 것은 그쪽 모양이 바뀐 것이다 — 다시 보내도 같으므로 재시도를 부르지 않는다. */ return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: e?.message ?? 'cannot translate the payload' } } } const items = batch?.items if (!Array.isArray(items)) { return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: 'the connector did not return a list of items' } } } /* * **번호 확인** — 이 자리가 프레임워크인 이유는 밀어 주는 모든 연결에 같은 규율이라야 하기 때문이다. * 커넥터마다 만들면 한 곳이 빠지고, 빠진 그 연결에서만 사실이 없어진다. * * 판정은 `takeInSequence` 하나가 한다 — **backfill 하는 길도 같은 함수를 지난다.** 여기서 따로 만들면 * 두 길이 다른 규칙으로 받고, 한쪽만 고쳐지는 날이 온다. */ const lane = webhookLaneOf((batch as any).lane) if (!lane) { return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: 'unknown webhook lane' } } } const scope = typeof batch.scope === 'string' && batch.scope.trim() ? batch.scope.trim() : undefined /* 열쇠에 트윈을 넣는다 — 연결 하나가 트윈 여럿을 만들고 커서는 연결마다 한 행이다. */ const key = scope ? pushCursorKey(instanceId, scope) : undefined const taken = takeInSequence({ items, scope, lastSeq: key ? seqOf((ref as any).liveCursor, key) : undefined }) if (taken.refused) { /* 반쯤 확인한 것을 확인했다고 말하지 않는다. */ return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: taken.refused } } } const numbered = taken.numbered const gap = taken.gap /* * 첫 봉투가 1번이 아니면 **그 사실을 남긴다.** 커서가 없는 연결에 21번이 오면 커서가 21 로 뛰고, * 그 앞 스무 건은 구멍으로도 안 세어진다. 실제로 plant 에서 그렇게 스무 건이 조용히 사라졌다. * * 손실이라고 말하지 않는다 — 늦게 붙은 트윈은 그 앞을 안 받기로 한 것이다. 수만 말하고 판단은 * 사람이 한다. */ if (taken.firstUnseenBefore) { twinWarn( `[twin-ingest] "${instanceId}" 의 ${scope ?? '(단위 없음)'} 를 ${taken.advancedSeq} 번부터 받기 시작했습니다 — ` + `그 앞 ${taken.firstUnseenBefore}건은 받은 적이 없습니다. 원본이 오래 돌고 있었으면 정상이고, ` + `보냈는데 못 받은 것이면 그만큼이 빠진 것입니다` ) } const advanced = taken.advancedSeq !== undefined && key ? { scope: key, seq: taken.advancedSeq } : undefined const records = taken.records if (!records.length) { /* * 옮길 것이 없는 것은 오류가 아니다 — 그쪽이 우리와 무관한 것을 보낸 것일 수 있다. 다만 첫 봉투부터 * 구멍이면 그것은 받은 것이 없는 것이고, 그 앞부터 달라고 해야 한다. */ if (gap) { if (scope) onGap?.(scope) return gapOutcome(scope, gap) } if (advanced) await saveSeq(store, ref as any, advanced.scope, advanced.seq, taken.firstSeq) return { status: WEBHOOK_STATUS.ok, body: { ok: true, offered: 0, applied: 0, duplicates: 0, vocabulary: 0, rejected: [], ...seqBody(scope, advanced, numbered, taken.firstUnseenBefore) } } } let result: IngestResult try { result = await ingest(instanceId, records, lane) } catch (e: any) { return { status: WEBHOOK_STATUS.failed, body: { ok: false, error: e?.message ?? 'ingest failed' } } } const rejected = result.rejected ?? [] /* * ── 떨어진 것에 **봉투 id** 를 붙인다 (2026-09-07 측정) ────────────────────── * 이 응답이 `{ record, errors }` 목록이었고, plant 은 그것을 **봉투 id 문자열 목록**으로 읽고 * 있었다. `Array.isArray` 는 통과하므로 그쪽 guard 도 안 걸렸다 — id 를 맞추는데 객체라 하나도 * 안 맞고 **묶음 전체가 「보냈음」으로 찍혔다.** * * 422 와 `ok: false` 를 보냈는데 그쪽 행은 재시도 0 · 오류 없음 · 보냈음이었다. 우리가 말했는데 * 상대가 못 알아듣는 모양이면, 말한 것이 아니다. * * 그래서 봉투 id 를 함께 싣는다. 보내는 쪽이 자기 행을 표시할 수 있고 까닭도 남는다. * * **id 를 못 찾으면 레코드를 그대로 싣는다.** 그때는 무엇이 떨어졌는지 알 방법이 그것뿐이고, * 빈 id 를 실으면 보내는 쪽이 아무 행도 못 찾으면서 「알았다」고 여긴다. * * 짝은 **객체 동일성**으로 짓는다. `records` 는 `items` 의 순서와 원소를 그대로 들고 있고 * (§`takeInSequence`), 그래서 커넥터를 고치지 않고도 봉투로 되짚을 수 있다. 값으로 비교하면 * 같은 모양의 레코드 둘이 서로의 id 를 가져간다. */ const forCaller = rejectedForCaller(rejected, items as { record?: unknown; eventId?: unknown }[]) if (result.applied === 0 && result.duplicates === 0 && rejected.length === 0 && !result.vocabulary) { /* * 넣었는데 한 건도 반영되지 않았고 중복도 거부도 아니면, 그 트윈이 실시간으로 돌지 않는 것이다. * 200 을 주면 그쪽은 전달된 줄 알고 그 사실을 버린다. * * **커서를 올리지 않는다.** 한 건도 안 들어갔는데 번호를 올리면 그 번호는 다시 오지 않고, 트윈이 * 뜬 뒤에 그 구간이 비어 있게 된다. * * ── Vocabulary is why this test needs a fourth term (2026-09-07) ────────── * This inference reads an absence as a cause, and one absence is legitimate: a batch that was * entirely vocabulary applies no events, has no duplicates and rejects nothing, and yet * everything in it landed (§`applyMasterData`). Without the last term the receiver answers * 503 to correct traffic, and the sender then holds those facts and retries every thirty * seconds for as long as they keep arriving — the reply invents a reason ("not running live") * that the numbers do not support. * * Found by a test written for the response sum, not by reading this line. * * ── Why this branch has to say something out loud ───────────────────────── * Measured today: a 503 from here leaves **no trace on either side.** The sender maps it to * backpressure and returns without touching `attempts` or `lastError`, and this branch logged * nothing, so a sender re-offering the same batch every thirty seconds is invisible to both. * * That is also why "it has not happened" could not be established: `attempts 0` on the seven * vocabulary envelopes was offered as evidence and is not evidence, because this path never * increments that counter. Telling a sender to come back forever is worth one line. */ twinWarn( `[twin-hook] "${instanceId}" 에 ${records.length}건이 왔는데 한 건도 앉지 않아 ` + `${NOT_LIVE_RETRY_MS / 1000}초 뒤 다시 오라고 답했습니다 — 트윈이 실시간으로 돌지 않는 것으로 봅니다. ` + `보내는 쪽은 이 답에 재시도 횟수를 남기지 않으므로, 되풀이되면 이 줄만 늘어납니다` ) return { status: WEBHOOK_STATUS.notLive, body: { ok: false, error: `twin "${instanceId}" is not running live — nothing was applied`, offered: records.length, retryAfterMs: NOT_LIVE_RETRY_MS } } } if (advanced) await saveSeq(store, ref as any, advanced.scope, advanced.seq, taken.firstSeq) /* * **구멍이 있으면 그것이 답을 정한다.** 앞의 것은 이미 들어갔으므로 `lastSeq` 까지는 보냄으로 표시하게 * 하고, 떨어진 것이 있으면 그것도 함께 싣는다 — 두 소식이 한 응답에 들어가야 보내는 쪽이 둘 다 한다. */ if (gap) { if (scope) onGap?.(scope) return gapOutcome(scope, gap, forCaller, advanced) } if (rejected.length) { /* * **다시 보내라고 하지 않는다.** 떨어진 것은 모양이 틀린 것이라 같은 것을 다시 보내도 또 떨어지고, * 함께 간 나머지는 이미 들어갔으므로 두 번 들어간다. */ return { status: WEBHOOK_STATUS.partial, body: { ok: false, error: `${rejected.length} of ${records.length} records did not pass validation`, offered: records.length, applied: result.applied, duplicates: result.duplicates, vocabulary: result.vocabulary ?? 0, rejected: forCaller, ...seqBody(scope, advanced, numbered, taken.firstUnseenBefore) } } } return { status: WEBHOOK_STATUS.ok, body: { ok: true, offered: records.length, applied: result.applied, duplicates: result.duplicates, /* * Always reported, even as zero — the sender's sum has a term for it either way, and a field * that appears only sometimes makes an absent one indistinguishable from a receiver that * does not count vocabulary at all. */ vocabulary: result.vocabulary ?? 0, rejected: [], ...seqBody(scope, advanced, numbered, taken.firstUnseenBefore) } } } /** * 유입이 돌려주는 것. * * 떨어진 것을 **수가 아니라 목록으로** 받는다. 수만 받으면 보내는 쪽에 「몇 건이 떨어졌다」밖에 말할 수 * 없고, 그러면 무엇을 따로 두어야 할지 알 수 없어 배치 전체를 다시 보내거나 전부 버린다. */ export interface IngestResult { applied: number duplicates: number rejected: { record: unknown; errors: string[] }[] /** * How many records were vocabulary rather than events — **needed so the reply's sum closes.** * * `liveIngest` has counted this all along and this boundary dropped it, because the callback was * typed as `IngestResult` and vocabulary was not on it. So `offered` counted those records and * none of the other three did, and a sender checking the sum would have seen a loss on ordinary * traffic (§`WebhookResponse.offered`). * * Optional because an ingest that handles no vocabulary need not say zero, and a caller that * simply does not know must not be made to claim none. */ vocabulary?: number } /** * 구멍을 알리는 응답 — **앞의 것은 받았다는 것을 함께 말한다.** * * `lastSeq` 를 빼면 보내는 쪽은 이미 들어간 것까지 다시 보내고, 그것이 매번 중복으로 걸러지면서 * 처리량만 먹는다. */ /* * 구멍을 본 자리에서 **바로 받는다** — 번호가 비었다는 것을 아는 가장 이른 순간이다. * * 기다리지 않는다. 훅의 응답이 이것 때문에 늦으면 보내는 쪽이 되풀이하고, 그러면 같은 봉투가 두 번 * 온다. 그리고 같은 단위를 1분 안에 두 번 부르지 않는다(§`FILL_DEBOUNCE_MS`) — 밀어 받는 쪽이 * 구멍 뒤의 봉투를 계속 보내면 그때마다 구멍 판정이 나기 때문이다. */ function gapOutcome( scope: string | undefined, gap: { expectedSeq: number; lastSeq: number }, /* * 떨어진 것 — 봉투 id 를 알면 그것을, 모르면 레코드를 싣는다(§`InboundItem.eventId`). * 보내는 쪽이 자기 행을 찾을 수 있어야 하고, 못 찾으면 무엇이 떨어졌는지라도 보여야 한다. */ rejected: RejectedForCaller[] = [], advanced?: { scope: string; seq: number } ): HookOutcome { return { status: WEBHOOK_STATUS.sequenceGap, body: { ok: false, error: 'sequence gap', scope, expectedSeq: gap.expectedSeq, lastSeq: advanced?.seq ?? gap.lastSeq, ...(rejected.length ? { rejected } : {}) } } } /** * 트윈이 안 돌 때 얼마나 쉬라고 할 것인가. * * 사람이 트윈을 띄워야 풀리므로 초 단위로 다시 두드릴 이유가 없다. 그렇다고 길게 잡으면 트윈이 뜬 뒤 * 그만큼 늦게 이어진다. */ const NOT_LIVE_RETRY_MS = 30_000 /** * 응답에 번호를 어떻게 적나 — **확인한 것과 확인하지 않은 것을 구별한다.** * * 번호를 매기지 않는 원본은 확인할 것이 없다. 그것을 응답에서 「이상 없다」와 같아 보이게 두면, 어느 * 연결이 연속성을 지키고 있는지 아무도 알 수 없다. */ /* * The return type is the contract's, narrowed to the fields this function fills. * * It was `Record`, and because the result is **spread** into the reply, an index * signature slips past the excess-property check entirely — the four fields below were unverified * even after `HookOutcome.body` was typed. Naming them here is what makes the check reach them. */ function seqBody( scope: string | undefined, advanced: { scope: string; seq: number } | undefined, numbered: boolean, firstUnseenBefore?: number ): Pick { if (!numbered) return { sequenceChecked: false } return { sequenceChecked: true, scope, lastSeq: advanced?.seq, /* * 첫 봉투였고 그 앞을 못 봤으면 **그 수를 보내는 쪽에 돌려준다.** 안 돌려주면 그 사실이 아무 데도 * 안 남는다 — 구멍으로도 안 세어지고 backfill 대상도 아니다(backfill 은 커서부터 묻는다). */ ...(firstUnseenBefore ? { firstUnseenBefore } : {}) } } /** * 커서에 번호를 적는다 — **반영한 뒤에만.** * * 넣기 전에 올리면, 넣다가 실패했을 때 그 번호는 다시 오지 않는다. 보내는 쪽은 200 을 못 받았으니 다시 * 보내겠지만, 그때는 커서가 이미 지나가 있어 `behind` 로 읽히고 버려진다. * * 적지 못하는 것은 사실 유입을 무르는 이유가 아니다 — 이미 들어간 사실은 그대로 두고, 다음 번호가 오면 * `gap` 으로 드러난다. 그래서 실패를 던지지 않고 남긴다. */ async function saveSeq( store: HookStore, ref: { id: string; liveCursor?: unknown }, scope: string, seq: number, firstSeq?: number ): Promise { const next = withSeq(ref.liveCursor, scope, seq, firstSeq) ref.liveCursor = next await store .saveCursor(ref.id, next) .catch(err => twinWarn(`[twin-hook] 번호를 커서에 적지 못했다 (${scope}=${seq}) — ${err?.message ?? err}`)) } /** * 훅이 저장소에 닿는 두 자리 — 연결을 찾는 것과 커서를 적는 것. * * 이 둘만 밖으로 낸다. 나머지는 인자만 보고 답하므로 시험이 데이터베이스를 세우지 않아도 된다. */ export interface HookStore { load(domainId: string, source: string): Promise saveCursor(refId: string, cursor: unknown): Promise }