/* * 웹훅의 규약 — 상태 코드 · 비밀값 · 어느 트윈인가. * * 데이터베이스를 부르는 부분과 갈라 둔다(§`reference-hook`): 규약만 있는 자리라야 시험이 엔티티를 * 끌고 오지 않는다. 실제로 그것 때문에 시험이 서지 않았다. * * ── 왜 필요한가 ───────────────────────────────────────────────────────────── * 지금 모든 커넥터가 주기적으로 물어봅니다(폴링). 그 방식은 두 가지를 치릅니다. 연결된 시스템이 값을 * 갱신하는 주기보다 자주 물으면 같은 값을 되풀이 받고, 그쪽 서버에 그만큼 부담을 줍니다. 실제로 * 태양광 발전소에 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`) — 이 파일이 따로 하지 않습니다. 응답에 몇 건이 중복이었는지 * 함께 알립니다. */ /* * `WebhookResponse` comes from the root, not from `@operato/ops-contract/webhook`. * * That subpath resolves to `webhook-signature`, which is a separate module kept out of the barrel * because it pulls in `node:crypto`. The module actually named `webhook.ts` — status codes, the * envelope, the response — reaches consumers through the root barrel. Importing the response type * from the subpath fails with TS2305, which is what happened here first. */ import { checkSequence, type WebhookResponse } from '@operato/ops-contract' import { WEBHOOK_HEADER, verifyWebhookSignature } from '@operato/ops-contract/webhook' /* * The body is typed by the contract, not by this file (2026-09-07). * * It was `Record`, which accepts anything — so nothing checked that what we reply * with is what a sender was told to expect. It was not: the contract declared `rejected` in prose * only, operato-plant guessed "a list of envelope ids", and a whole batch was stamped delivered * while one envelope never landed (mes_outbox_events seq 178 — SENT, attempts 0, no error). * * With `WebhookResponse` here, adding a field to a reply without declaring it fails the build on * this side, which is the side that would otherwise ship the surprise. */ /** 훅 요청의 결과 — 상태 코드와 본문을 함께 정한다(부르는 쪽이 그대로 답한다). */ export interface HookOutcome { status: number body: WebhookResponse } /** * 응답 코드 규약 — **계약이 정하고 여기서는 그것을 쓴다**(2026-08-31). * * 예전에는 이 표가 여기 있었고 보내는 쪽(operato-plant)에 또 있었다. 그래서 같은 409 를 양쪽이 다른 * 뜻으로 썼다 — 여기서는 「그 트윈이 실시간으로 돌지 않는다」, 저쪽에서는 「번호가 비었다」였다. * 붙였다면 트윈이 안 도는 동안 보내는 쪽이 같은 구간을 계속 다시 보냈다. * * 판정표는 만드는 쪽과 읽는 쪽이 합의해야 하는 것이라 계약 층에 있다(`@operato/ops-contract`). * `notLive` 가 409 에서 503 으로 옮겨진 이유도 그 파일에 적혀 있다. * * **여기서 이름을 바꿔 다시 내보내지 않는다.** 부르는 쪽은 `WEBHOOK_STATUS` 를 계약에서 바로 가져온다. * 별명을 두면 같은 표에 이름이 둘이 되고, 다음 사람이 어느 쪽이 정본인지 물어야 한다. */ /** 헤더에서 비밀값을 꺼낸다 — 두 가지 방식을 받는다(밀어 주는 쪽의 관습이 갈린다). */ export function secretFromHeaders(headers: Record | undefined): string { const pick = (k: string) => String((headers ?? {})[k] ?? '').trim() const direct = pick('x-twin-hook-secret') if (direct) return direct const auth = pick('authorization') return auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '' } /** * 비밀값 비교 — **길이가 다르면 바로 거절하고, 같으면 끝까지 비교한다.** * * 앞에서 다른 것을 발견하고 곧바로 답하면 응답 시간이 달라지고, 그 차이로 비밀값을 한 글자씩 알아낼 수 * 있습니다. 그래서 같은 길이면 전부를 비교합니다. */ export function secretMatches(given: string, expected: string): boolean { if (!expected || !given || given.length !== expected.length) return false let diff = 0 for (let i = 0; i < expected.length; i++) diff |= given.charCodeAt(i) ^ expected.charCodeAt(i) return diff === 0 } /** * 인증 — **서명이 왔으면 서명을 보고, 아니면 비밀값을 본다.** * * 두 방식을 두는 이유는 밀어 주는 쪽의 사정이 다르기 때문이다. 이미 붙어 있는 커넥터는 비밀값을 헤더에 * 싣는 방식으로 만들어졌고, 새로 만드는 것(operato-plant)은 서명을 쓴다. 서명 쪽이 낫다 — 비밀값을 그대로 * 싣는 방식은 그 요청을 그대로 다시 보내는 것을 막지 못한다. * * **서명이 왔는데 확인할 수 없으면 거절한다.** 받은 바이트를 들고 있지 않으면(`rawBody` 가 없으면) * 파싱한 객체를 다시 문자열로 만들어 확인하고 싶어지는데, 키 순서와 공백이 달라져 어차피 맞지 않는다. * 맞지 않는 것을 통과시키면 서명이 있으나 마나 한 것이 된다. */ export function authorizeHook(args: { headers: Record | undefined /** 받은 바이트 그대로. 서명을 쓰지 않는 연결에서는 없어도 된다. */ rawBody: string | undefined /** 연결 설정에 저장된 비밀값. 서명과 비밀값이 같은 값을 쓴다. */ secret: string nowMs: number }): { ok: true; method: 'signature' | 'secret' } | { ok: false; reason: string } { const { headers, rawBody, secret, nowMs } = args const pick = (k: string) => String((headers ?? {})[k] ?? '').trim() const signature = pick(WEBHOOK_HEADER.signature) if (signature) { if (rawBody === undefined) { return { ok: false, reason: 'signed request but the raw body was not kept — cannot verify' } } const verdict = verifyWebhookSignature({ secret, timestamp: pick(WEBHOOK_HEADER.timestamp), signature, body: rawBody, nowMs }) /* `=== false` 로 본다 — 이 저장소는 `strictNullChecks` 가 꺼져 있어 참·거짓만으로는 좁혀지지 않는다. */ if (verdict.ok === false) return { ok: false, reason: `signature ${verdict.reason}` } return { ok: true, method: 'signature' } } return secretMatches(secretFromHeaders(headers), secret) ? { ok: true, method: 'secret' } : { ok: false, reason: 'hook secret does not match' } } /** * 커서에 적을 열쇠 — **트윈까지 넣는다.** * * ── 왜 (2026-08-31, MES 레인 질문에서 나옴) ─────────────────────────────── * 커서는 **연결마다** 한 행(`TwinReference.liveCursor`)에 산다. 그런데 연결 하나가 트윈 여럿을 * 만든다(`scopeSpec.produced` 가 목록이다). 보내는 쪽의 단위 이름만으로 열쇠를 잡으면, 같은 연결의 * 두 공장이 같은 이름(`EVENTS`)을 쓸 때 **한 계수기를 함께 쓴다.** * * 그러면 A 공장이 5까지 올린 뒤 B 공장이 1을 보내면 이미 본 번호가 되어 버려지고, A 가 6을 보내면 * B 의 번호와 어긋나 끊임없이 「빠졌다」가 된다. 같은 부류가 이 저장소에서 이미 났다 — 두 공장의 * 같은 번호가 한 자리에 겹쳤다. * * 보내는 쪽에 트윈 이름을 넣으라고 하지 않는다. 어디로 갈지는 주소가 이미 말했고, 그것을 본문에도 * 적으라고 하면 둘이 어긋나는 날 어느 쪽을 믿을지 정해야 한다. */ export function pushCursorKey(instanceId: string, scope: string): string { return `${instanceId}::${scope}` } /** * 커서에서 그 단위의 마지막 번호를 꺼낸다. * * 커서 모양은 `{ streams: { [흐름]: { since?, seen[], seq? } } }` 이고 **흐름 이름은 어댑터가 정한다** * (§`TwinReference.liveCursor`). 밀어 주는 연결에서는 그 이름이 곧 번호를 매기는 단위다. * * 자리를 따로 만들지 않는 이유는 「재기동을 넘어 사는 진행 위치」가 두 벌이 되기 때문이다. 폴링이 쓰는 * `since` 와 밀어 주기가 쓰는 `seq` 는 둘 다 「이 흐름을 어디까지 읽었나」이고 원본이 되풀어 줄 수 있다. */ export function seqOf(cursor: unknown, scope: string): number | undefined { const streams = (cursor as any)?.streams const seq = streams?.[scope]?.seq return typeof seq === 'number' && Number.isInteger(seq) ? seq : undefined } /** 그 단위의 번호만 바꾼 커서를 만든다 — 다른 흐름과 `since`·`seen` 은 그대로 둔다. */ export function withSeq(cursor: unknown, scope: string, seq: number, firstSeq?: number): Record { const base = (cursor && typeof cursor === 'object' ? (cursor as Record) : {}) as Record const streams = base.streams && typeof base.streams === 'object' ? { ...base.streams } : {} const prev = streams[scope] ?? {} /* * **`firstSeq` 는 한 번만 적힌다.** 이 연결이 이 단위를 몇 번부터 받기 시작했는지는 바뀌지 않는 * 사실이고, 나중에 덮으면 「앞이 비었나」를 다시 물을 수 없다. * * 그래서 첫 봉투 판정이 있을 때만 넘어온다(`SequencedTake.firstSeq`). 커서가 이미 나아간 뒤에 * 이 값을 만들면 그 번호는 첫 번호가 아니라 그때 마침 온 번호다. */ streams[scope] = { ...prev, seq, ...(firstSeq !== undefined && prev.firstSeq === undefined ? { firstSeq } : {}) } return { ...base, streams } } /** * 이 연결이 이 단위를 **몇 번부터 받기 시작했나** — 적힌 적이 없으면 `undefined`. * * 「앞이 비었나」를 답하려면 이 값이 있어야 한다. 없으면 **모른다고 말해야 한다** — 0 으로 답하면 * 1번부터 다 받은 것으로 읽힌다. */ export function firstSeqOf(cursor: unknown, scope: string): number | undefined { const seq = (cursor as any)?.streams?.[scope]?.firstSeq return typeof seq === 'number' && Number.isInteger(seq) ? seq : undefined } /** * **번호대로 받을 수 있는 데까지 자른다** — 밀어 받는 길과 backfill 하는 길이 이 함수 하나를 지난다. * * ── 왜 갈라 두나 (2026-08-31) ──────────────────────────────────────────── * 이 판정이 훅 안에 박혀 있었다. backfill(`fetchSince`)이 같은 판정을 해야 하는데, 거기서 다시 만들면 * **두 길이 다른 규칙으로 받는다** — 한쪽만 고쳐지는 날이 오고, 어긋난 그 길에서만 사실이 사라진다. * * 번호 확인을 커넥터가 아니라 프레임워크로 가져온 것과 같은 이유다. 자리가 하나여야 한다. * * ── 첫 구멍에서 멈춘다 ─────────────────────────────────────────────────── * 구멍 뒤의 봉투를 받아들이면 그 구멍이 영영 메워지지 않는다. 보내는 쪽은 `expectedSeq` 부터 다시 * 보내고, 뒤엣것은 그때 함께 온다. */ export interface SequencedTake { /** 받아들인 레코드 — 구멍 앞까지다. */ records: unknown[] /** 번호를 확인했나. 번호를 매기지 않는 원본은 거짓이고, 그 사실을 응답에 남긴다. */ numbered: boolean /** 커서에 적을 번호 — 받은 것이 없으면 없다. */ advancedSeq?: number /** 구멍이 났다 — 그 앞부터 다시 받아야 한다. */ gap?: { expectedSeq: number; lastSeq: number } /** * 이 단위의 **첫 봉투였다** — 그 앞으로 몇 개를 못 봤나. * * ── 왜 이 칸이 생겼나 (2026-09-06 실측) ────────────────────────────────── * plant 아웃박스의 1~20 이 400 으로 거절돼 그 연결에 커서가 없었다. 다음에 온 21번이 첫 봉투로 * 앉으며 **커서가 21 로 뛰었다.** 커서가 0 이었다면 같은 21이 구멍이었다. * * 스무 건이 없는데 구멍으로도 안 세어지고, 경고도 없고, backfill 대상도 아니었다 — backfill 은 커서부터 * 묻는다. 그 사실이 아무 데도 안 남았다. * * **손실이라고 말하지 않는다.** 원본이 오래 돌고 있는데 트윈을 나중에 붙이면 그 아래는 애초에 * 안 받기로 한 것이다. 계약은 둘을 구별할 수 없으므로 수만 낸다. * * 0 이면 1번부터 시작한 것이라 이 칸을 내지 않는다 — 말할 것이 없다. */ firstUnseenBefore?: number /** * 이 묶음의 첫 봉투가 **이 단위의 첫 봉투였다** — 그 번호. * * `firstUnseenBefore` 와 다르다: 이쪽은 1번부터 시작한 연결에도 있다(값 1). 커서에 적어 두면 * 나중에 「앞이 비었나」를 답할 수 있다. */ firstSeq?: number /** * 이 묶음에서 **가장 낮게 받아들인 번호** — 하나도 못 받았으면 없다. * * `firstSeq` 와 다르다. `firstSeq` 는 「이 단위의 첫 봉투였다」일 때만 있고, 이쪽은 언제나 있다. * * 밑에서부터 다시 받을 때 이것이 필요하다. `from: 0` 으로 부르면 커서가 0 이므로 seq 1 은 * `next` 판정이고 `firstSeq` 가 안 생긴다 — 그런데 우리는 실제로 1번부터 갖게 된다. 그 사실을 * 적어야 「앞이 비었나」에 답할 수 있다. */ lowestSeq?: number /** * 받을 수 없는 묶음 — 그 이유. * * 번호가 있는 봉투와 없는 봉투가 섞였거나 번호가 정수가 아니다. 둘 다 보내는 쪽의 결함이고, * **반쯤 확인한 것을 확인했다고 말하지 않는다.** */ refused?: string } export function takeInSequence(args: { items: { record: unknown; seq?: number; at?: string }[] scope?: string /** 이 단위에서 마지막으로 받아들인 번호. 받은 적이 없으면 `undefined`. */ lastSeq?: number }): SequencedTake { const { items, scope, lastSeq } = args const withSeq = items.filter(i => typeof i?.seq === 'number').length if (withSeq && withSeq !== items.length) { return { records: [], numbered: false, refused: `mixed batch — ${withSeq} of ${items.length} items carry a sequence number` } } const numbered = withSeq > 0 && !!scope if (!numbered) return { records: items.map(i => i.record), numbered: false } let cursor = lastSeq let take = 0 let gap: { expectedSeq: number; lastSeq: number } | undefined /* 첫 봉투가 몇 번부터 시작했나 — 그 앞을 못 본 사실을 응답에 남긴다. */ let firstUnseenBefore: number | undefined /* 이 단위를 몇 번부터 받기 시작했나 — 커서에 적어 두면 「앞이 비었나」를 나중에 답할 수 있다. */ let firstSeq: number | undefined /* 이 묶음에서 가장 낮게 받아들인 번호 — 밑에서부터 다시 받을 때 이것이 firstSeq 가 된다. */ let lowestSeq: number | undefined for (const item of items) { let verdict try { verdict = checkSequence(cursor, item.seq as number) } catch (e: any) { return { records: [], numbered: true, refused: e?.message ?? 'bad sequence number' } } if (verdict.kind === 'gap') { gap = { expectedSeq: verdict.expectedSeq, lastSeq: verdict.lastSeq } break } if (verdict.kind === 'first') { firstSeq = item.seq as number /* * 계약이 `unseenBefore` 를 내면 그것을 쓴다. 설치된 버전이 그보다 오래됐으면 번호에서 센다 — * 첫 봉투의 번호 아래로 그만큼이 있고 우리는 그것을 받은 적이 없다. * * ⚠ 이 두 번째 길은 계약 0.9.9 가 깔리면 지운다. 그때까지 두는 이유는, 계약을 기다리며 * 비워 두면 **코드는 있는데 아무 일도 안 하는 상태**가 되고 그것이 이 결함의 원인과 같은 * 모양이기 때문이다. */ const unseen = (verdict as any).unseenBefore ?? (item.seq as number) - 1 if (unseen > 0) firstUnseenBefore = unseen } if (lowestSeq === undefined) lowestSeq = item.seq as number cursor = verdict.lastSeq take++ } return { /* * **봉투의 시각을 레코드에 얹는다** — 레코드가 자기 시각을 말하면 그것이 이긴다. * * 시각을 안 싣는 레코드는 ingest 시각으로 떨어지고, 그 값이 실행할 때마다 달라서 fact identity 가 * 매번 바뀐다. 그래서 같은 사실을 다시 받으면 dedupe 가 안 되고 새 사실로 앉는다 — * `fillTwinScope` 를 두 번 돌렸더니 저널이 40 → 69건이 됐다(2026-09-06 측정). */ records: items.slice(0, take).map(i => { const r = i.record as any if (!i.at || r == null || typeof r !== 'object') return i.record return r.at === undefined && r.eventTime === undefined ? { ...r, at: i.at } : i.record }), numbered: true, /* 앞의 것을 하나도 못 받았으면 커서를 올릴 것이 없다. */ ...(take > 0 && typeof cursor === 'number' ? { advancedSeq: cursor } : {}), ...(gap ? { gap } : {}), ...(firstUnseenBefore ? { firstUnseenBefore } : {}), ...(firstSeq !== undefined ? { firstSeq } : {}), ...(lowestSeq !== undefined ? { lowestSeq } : {}) } } /** 이 연결이 이 트윈을 만들었나 — 남의 트윈에 밀어 넣지 못하게. */ export function producedInstance(ref: { scopeSpec?: any } | null | undefined, instanceId: string): { siteId: string } | undefined { const produced: any[] = (ref?.scopeSpec as any)?.produced ?? [] const hit = produced.find(p => String(p?.instanceId ?? '') === instanceId) return hit?.siteId ? { siteId: String(hit.siteId) } : undefined }