@operato/ops-contract 0.9.13 → 0.9.15

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/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ export * from './wms-profile.ts';
18
18
  export * from './yms-profile.ts';
19
19
  export * from './canonical-record.ts';
20
20
  export * from './webhook.ts';
21
+ export * from './webhook-secret.ts';
21
22
  export * from './oee.ts';
22
23
  export * from './rated-usage.ts';
23
24
  export * from './yield.ts';
package/dist/index.js CHANGED
@@ -40,6 +40,8 @@ export * from "./yms-profile.js";
40
40
  export * from "./canonical-record.js";
41
41
  /* `webhook-signature.ts` 는 여기 없다 — `node:crypto` 를 쓰므로 `@operato/ops-contract/webhook` 으로만 나간다. */
42
42
  export * from "./webhook.js";
43
+ /* 비밀값 **이름** 규약 — 값을 읽지 않으므로 여기서 나간다(§`webhook-secret`). */
44
+ export * from "./webhook-secret.js";
43
45
  export * from "./oee.js";
44
46
  export * from "./rated-usage.js";
45
47
  export * from "./yield.js";
@@ -0,0 +1,138 @@
1
+ /**
2
+ * ① 이름 규약 — **두 벌로 나눈다.**
3
+ *
4
+ * 서명값은 대칭키다. 검증할 수 있는 쪽은 서명도 만들 수 있으므로, 한 벌로 두면 우리 사실을 검증하는
5
+ * 상대가 그 키로 우리에게 지시를 위조해 넣는다.
6
+ *
7
+ * ```
8
+ * 나가는 쪽이 새면 남의 시스템에 거짓 사실이 앉는다
9
+ * 들어오는 쪽이 새면 남이 우리 공장에 지시를 꽂고, 사람이 그것으로 물건을 만든다
10
+ * ```
11
+ *
12
+ * 뒤엣것이 더 무겁다. 무게가 다르면 키도 달라야 한다.
13
+ *
14
+ * `OUTBOX`/`INTAKE` 가 아니라 `SIGNING`/`VERIFY` 인 이유: 앞엣것은 **우리 구조**의 이름이고 구조는
15
+ * 바뀐다. 서명하나 검증하나는 그 키가 무엇에 쓰이는지라 모듈이 바뀌어도 안 바뀐다.
16
+ *
17
+ * `MES_` 가 아닌 이유: 이 계약은 한 제품의 것이 아니다(§`WEBHOOK_HEADER` 와 같은 판단).
18
+ */
19
+ export declare const WEBHOOK_SECRET_BASE: {
20
+ /** 우리가 서명할 때 — 나가는 쪽. */
21
+ readonly signing: "WEBHOOK_SIGNING_SECRET";
22
+ /** 우리가 검증할 때 — 들어오는 쪽. */
23
+ readonly verify: "WEBHOOK_VERIFY_SECRET";
24
+ };
25
+ export type WebhookSecretPurpose = keyof typeof WEBHOOK_SECRET_BASE;
26
+ /**
27
+ * ② 파생 규칙이 성립하려면 이름이 이 모양이어야 한다 — 소문자·숫자, 이음은 하이픈 하나.
28
+ *
29
+ * ── 왜 부호화하지 않고 입력을 좁혔나 ───────────────────────────────────────
30
+ * 환경 변수 이름에는 `[A-Za-z0-9_]` 만 쓸 수 있다. 그래서 도메인 이름을 그 집합으로 접어야 하는데,
31
+ * 접으면 **두 이름이 한 이름으로 눌린다** — plant 의 규칙이 `a-b` 와 `a_b` 를 둘 다 `A_B` 로 만들고
32
+ * 있었고, 눌리면 한 도메인이 다른 도메인의 키로 열린다. 그 파일은 그 사실을 주석으로 알고 있었다.
33
+ *
34
+ * 부호화(`_2D` 같은 것)로 풀 수도 있지만, 이 이름은 **사람이 비밀 관리 도구에 손으로 넣는 값**이다.
35
+ * `MIRATEK_2DGUNSAN` 을 읽고 쓰게 하면 그 자리에서 다른 실수가 난다.
36
+ *
37
+ * 그래서 접는 것을 없애는 대신 **접을 필요가 없는 입력만 받는다.** 밑줄이 없는 이름에서
38
+ * `-` → `_` 는 되돌릴 수 있고, 눌리지 않는다. 하이픈이 연달아 오지 못하게 한 것은 구분자
39
+ * `__` 가 그 자리에서만 나오게 하려는 것이다.
40
+ *
41
+ * 이 모양이 아닌 이름은 **거절한다.** 접어서 통과시키면 그 순간부터 눌림이 조용히 산다.
42
+ */
43
+ export declare const WEBHOOK_SECRET_NAME_SHAPE: RegExp;
44
+ /** 그 조각이 파생에 쓸 수 있는 모양인가. */
45
+ export declare function isWebhookSecretSegment(segment: string): boolean;
46
+ /**
47
+ * ② 파생 — `(base, domain, peer?)` → 환경 변수 이름.
48
+ *
49
+ * ```
50
+ * ('WEBHOOK_VERIFY_SECRET', 'miratek-gunsan', 'twin-a') → WEBHOOK_VERIFY_SECRET__MIRATEK_GUNSAN__TWIN_A
51
+ * ('WEBHOOK_SIGNING_SECRET', 'miratek-gunsan') → WEBHOOK_SIGNING_SECRET__MIRATEK_GUNSAN
52
+ * ```
53
+ *
54
+ * **되돌릴 수 있다.** 조각에 밑줄이 없으니 `__` 는 구분자에서만 나오고, `_` 는 하이픈에서만 나온다.
55
+ * 그래서 두 쌍이 한 이름으로 눌릴 수 없다.
56
+ *
57
+ * 모양이 아닌 조각은 던진다 — 접어 통과시키면 눌림이 생기고, 눌림은 **한 공장이 다른 공장의 키로
58
+ * 열리는** 것이다. 이름을 정하는 자리에서 걸리는 것이 낫다.
59
+ */
60
+ export declare function webhookSecretEnvName(base: string, domain: string, peer?: string): string;
61
+ /** 이 후보가 무엇까지 고정하나 — 사다리의 칸을 이 값으로 판단한다. */
62
+ export type WebhookSecretPin = 'peer' | 'domain' | 'installation';
63
+ export interface WebhookSecretCandidate {
64
+ /** 찾아볼 환경 변수 이름. */
65
+ name: string;
66
+ /** 이 이름이 무엇까지 고정하나. */
67
+ pins: WebhookSecretPin;
68
+ /** 배포된 설치본을 안 깨뜨리려고 남긴 칸인가 — 열리면 알려야 한다. */
69
+ compatibility: boolean;
70
+ }
71
+ export interface WebhookSecretLookup {
72
+ purpose: WebhookSecretPurpose;
73
+ /** 우리 테넌트의 subdomain. */
74
+ domain: string;
75
+ /** 상대의 이름 — **우리 이름이 아니다.** */
76
+ peer: string;
77
+ /**
78
+ * 옛 이름들 — 이 설치본이 예전에 쓰던 변수. 배포된 것을 안 깨뜨리려고 받는다.
79
+ *
80
+ * 옛 이름을 끊으면 그 이름으로만 돌아가는 설치본의 사실이 그 순간 멈춘다. 그래서 지우지 않고
81
+ * 받되, 열리면 부르는 쪽이 한 번 알린다 — 조용히 되면 아무도 새 이름으로 옮기지 않는다.
82
+ */
83
+ legacyBases?: readonly string[];
84
+ /**
85
+ * **검증 쪽에서 넓은 칸까지 내려가는 것을 허락한다.** 기본은 거짓이고, 그것이 ③ 규칙이다.
86
+ *
87
+ * 이 인자를 참으로 주려면 부르는 쪽이 **그 도메인에 상대가 하나뿐임을 확인**해야 한다. 둘이면
88
+ * 둘이 같은 키로 풀리고, 그 순간 주소가 「이 사실이 누구 것인가」를 정하는데 아무도 그것을
89
+ * 검사하지 않는다 — 상대 A 가 제대로 서명한 봉투를 상대 B 의 주소에 앉힐 수 있다
90
+ * (§`webhook-signature` 의 불변식: 주소는 키가 이미 고정한 것만 정할 수 있다).
91
+ *
92
+ * 그리고 그 상태는 **두 번째 상대가 붙는 날 아무 경고 없이 끝난다.** 이름을 명시적으로 주게 한
93
+ * 이유가 그것이다 — 코드에서 찾을 수 있어야 한다.
94
+ */
95
+ allowSinglePeerFallback?: boolean;
96
+ }
97
+ /**
98
+ * ③ 사다리 — 찾아볼 이름을 순서대로.
99
+ *
100
+ * ```
101
+ * 서명(나가는 쪽) 상대별 → 도메인별 → 옛 이름 우리가 상대를 알고 보낸다
102
+ * 검증(들어오는 쪽) 상대별 하나 넓은 칸으로 내려가지 않는다
103
+ * ```
104
+ *
105
+ * **나누는 선은 「고정 범위가 넓어지는가」다.** 같은 것을 고정한 채 이름만 옛것인 칸은 내려가도
106
+ * 되고, 고정 범위가 넓어지는 칸은 안 된다. 검증 쪽에서 넓어지면 아무 상대나 통과한다.
107
+ *
108
+ * 검증 쪽이 못 찾으면 ④ 로 간다 — 거절한다. 「안 붙었다」와 「붙었는데 아무 키나 통한다」는 다른
109
+ * 상태이고, 뒤엣것은 사람이 볼 수 없다.
110
+ */
111
+ export declare function webhookSecretCandidates(lookup: WebhookSecretLookup): WebhookSecretCandidate[];
112
+ /**
113
+ * ④ 없을 때 — **거절한다. 그리고 응답에 변수 이름을 넣지 않는다.**
114
+ *
115
+ * 거절 사유는 이미 있다(`WebhookSignatureFailure` 의 `'no-secret'`). 그 사유는 응답에 나가도 된다 —
116
+ * 보내는 쪽이 무엇을 고쳐야 하는지 알아야 한다.
117
+ *
118
+ * **변수 이름은 나가면 안 된다.** 자격을 못 낸 요청에 「이 이름의 변수를 채우면 열린다」고 답하는
119
+ * 것이고, 그 이름이 키 선택 방식과 **어떤 상대가 등록되어 있는지**를 함께 말한다.
120
+ *
121
+ * 그래서 이 문장은 **로그용**이다. 운영하는 사람은 프로세스 로그를 보고, 요청을 보낸 쪽은 못 본다.
122
+ */
123
+ export declare function webhookSecretMissingNote(candidates: readonly WebhookSecretCandidate[]): string;
124
+ /**
125
+ * 사다리를 실제로 걸어 값을 찾는다 — 환경을 **인자로 받는다.**
126
+ *
127
+ * `process.env` 를 여기서 읽지 않는 이유는 머리말과 같다. 그리고 시험이 값을 손에 들고 걸을 수 있다.
128
+ *
129
+ * 돌려주는 것에 **어느 칸에서 열렸는지**가 있다. `compatibility` 가 참인 칸에서 열렸으면 부르는 쪽이
130
+ * 한 번 알린다 — 조용히 되면 아무도 새 이름으로 옮기지 않는다.
131
+ */
132
+ export declare function webhookSecretFrom(env: Record<string, string | undefined>, lookup: WebhookSecretLookup): {
133
+ secret: string;
134
+ opened: WebhookSecretCandidate;
135
+ } | {
136
+ secret: undefined;
137
+ tried: WebhookSecretCandidate[];
138
+ };
@@ -0,0 +1,154 @@
1
+ /*
2
+ * ═══════════════════════════════════════════════════════════════════════════
3
+ * 어느 키가 어느 문을 여는가 — **이름 규약 · 파생 · 사다리 · 없을 때.**
4
+ *
5
+ * ── 왜 계약에 있나 (2026-09-08) ────────────────────────────────────────────
6
+ * 이 넷을 `operato-plant` 안에서 정하고 있었다(`server/service/domain-secret.ts`). 앱이 정하면
7
+ * `operato-wms` 가 붙는 날 그쪽이 또 적고, 셋이 **서로 다른 사다리**를 갖는다. 그러면 「이 설치본은
8
+ * 어느 키까지 내려가나」에 답할 자리가 없다.
9
+ *
10
+ * 그리고 이 넷은 서명과 검증이 **한 글자까지 같아야** 성립한다 — 서명 입력이 그런 것과 같은
11
+ * 이유로(§`webhook-signature`) 만드는 규칙과 찾는 규칙이 한 파일에 있어야 한다.
12
+ *
13
+ * ── 값은 여기 없다 ────────────────────────────────────────────────────────
14
+ * 이 파일은 **이름만** 만든다. `process.env` 를 읽지 않는다 — 그래야 규칙을 값 없이 시험할 수 있고,
15
+ * 이 패키지를 쓰는 브라우저 번들이 `node:process` 를 묶으려 하지 않는다. 읽는 것은 부르는 쪽이다.
16
+ *
17
+ * 비밀값은 표에 두지 않는다(§`WebhookDestination.params` 도 같은 규율). 데이터베이스·백업·화면
18
+ * 기록에 남고 한 번 새면 되돌릴 수 없다. **값은 환경에 있고, 어느 상대 것인지는 이름이 말한다.**
19
+ * ═══════════════════════════════════════════════════════════════════════════
20
+ */
21
+ /**
22
+ * ① 이름 규약 — **두 벌로 나눈다.**
23
+ *
24
+ * 서명값은 대칭키다. 검증할 수 있는 쪽은 서명도 만들 수 있으므로, 한 벌로 두면 우리 사실을 검증하는
25
+ * 상대가 그 키로 우리에게 지시를 위조해 넣는다.
26
+ *
27
+ * ```
28
+ * 나가는 쪽이 새면 남의 시스템에 거짓 사실이 앉는다
29
+ * 들어오는 쪽이 새면 남이 우리 공장에 지시를 꽂고, 사람이 그것으로 물건을 만든다
30
+ * ```
31
+ *
32
+ * 뒤엣것이 더 무겁다. 무게가 다르면 키도 달라야 한다.
33
+ *
34
+ * `OUTBOX`/`INTAKE` 가 아니라 `SIGNING`/`VERIFY` 인 이유: 앞엣것은 **우리 구조**의 이름이고 구조는
35
+ * 바뀐다. 서명하나 검증하나는 그 키가 무엇에 쓰이는지라 모듈이 바뀌어도 안 바뀐다.
36
+ *
37
+ * `MES_` 가 아닌 이유: 이 계약은 한 제품의 것이 아니다(§`WEBHOOK_HEADER` 와 같은 판단).
38
+ */
39
+ export const WEBHOOK_SECRET_BASE = {
40
+ /** 우리가 서명할 때 — 나가는 쪽. */
41
+ signing: 'WEBHOOK_SIGNING_SECRET',
42
+ /** 우리가 검증할 때 — 들어오는 쪽. */
43
+ verify: 'WEBHOOK_VERIFY_SECRET'
44
+ };
45
+ /**
46
+ * ② 파생 규칙이 성립하려면 이름이 이 모양이어야 한다 — 소문자·숫자, 이음은 하이픈 하나.
47
+ *
48
+ * ── 왜 부호화하지 않고 입력을 좁혔나 ───────────────────────────────────────
49
+ * 환경 변수 이름에는 `[A-Za-z0-9_]` 만 쓸 수 있다. 그래서 도메인 이름을 그 집합으로 접어야 하는데,
50
+ * 접으면 **두 이름이 한 이름으로 눌린다** — plant 의 규칙이 `a-b` 와 `a_b` 를 둘 다 `A_B` 로 만들고
51
+ * 있었고, 눌리면 한 도메인이 다른 도메인의 키로 열린다. 그 파일은 그 사실을 주석으로 알고 있었다.
52
+ *
53
+ * 부호화(`_2D` 같은 것)로 풀 수도 있지만, 이 이름은 **사람이 비밀 관리 도구에 손으로 넣는 값**이다.
54
+ * `MIRATEK_2DGUNSAN` 을 읽고 쓰게 하면 그 자리에서 다른 실수가 난다.
55
+ *
56
+ * 그래서 접는 것을 없애는 대신 **접을 필요가 없는 입력만 받는다.** 밑줄이 없는 이름에서
57
+ * `-` → `_` 는 되돌릴 수 있고, 눌리지 않는다. 하이픈이 연달아 오지 못하게 한 것은 구분자
58
+ * `__` 가 그 자리에서만 나오게 하려는 것이다.
59
+ *
60
+ * 이 모양이 아닌 이름은 **거절한다.** 접어서 통과시키면 그 순간부터 눌림이 조용히 산다.
61
+ */
62
+ export const WEBHOOK_SECRET_NAME_SHAPE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
63
+ /** 그 조각이 파생에 쓸 수 있는 모양인가. */
64
+ export function isWebhookSecretSegment(segment) {
65
+ return WEBHOOK_SECRET_NAME_SHAPE.test(segment);
66
+ }
67
+ /**
68
+ * ② 파생 — `(base, domain, peer?)` → 환경 변수 이름.
69
+ *
70
+ * ```
71
+ * ('WEBHOOK_VERIFY_SECRET', 'miratek-gunsan', 'twin-a') → WEBHOOK_VERIFY_SECRET__MIRATEK_GUNSAN__TWIN_A
72
+ * ('WEBHOOK_SIGNING_SECRET', 'miratek-gunsan') → WEBHOOK_SIGNING_SECRET__MIRATEK_GUNSAN
73
+ * ```
74
+ *
75
+ * **되돌릴 수 있다.** 조각에 밑줄이 없으니 `__` 는 구분자에서만 나오고, `_` 는 하이픈에서만 나온다.
76
+ * 그래서 두 쌍이 한 이름으로 눌릴 수 없다.
77
+ *
78
+ * 모양이 아닌 조각은 던진다 — 접어 통과시키면 눌림이 생기고, 눌림은 **한 공장이 다른 공장의 키로
79
+ * 열리는** 것이다. 이름을 정하는 자리에서 걸리는 것이 낫다.
80
+ */
81
+ export function webhookSecretEnvName(base, domain, peer) {
82
+ const segments = peer === undefined ? [domain] : [domain, peer];
83
+ for (const segment of segments) {
84
+ if (!isWebhookSecretSegment(segment)) {
85
+ throw new Error(`webhookSecretEnvName: "${segment}" 는 비밀값 이름에 쓸 수 없다 — ` +
86
+ '소문자·숫자와 이음 하이픈만 쓴다(예: miratek-gunsan). ' +
87
+ '밑줄이나 대문자가 섞이면 두 이름이 한 환경 변수로 눌리고, 한 쪽이 다른 쪽의 키로 열린다.');
88
+ }
89
+ }
90
+ return [base, ...segments.map(s => s.toUpperCase().replace(/-/g, '_'))].join('__');
91
+ }
92
+ /**
93
+ * ③ 사다리 — 찾아볼 이름을 순서대로.
94
+ *
95
+ * ```
96
+ * 서명(나가는 쪽) 상대별 → 도메인별 → 옛 이름 우리가 상대를 알고 보낸다
97
+ * 검증(들어오는 쪽) 상대별 하나 넓은 칸으로 내려가지 않는다
98
+ * ```
99
+ *
100
+ * **나누는 선은 「고정 범위가 넓어지는가」다.** 같은 것을 고정한 채 이름만 옛것인 칸은 내려가도
101
+ * 되고, 고정 범위가 넓어지는 칸은 안 된다. 검증 쪽에서 넓어지면 아무 상대나 통과한다.
102
+ *
103
+ * 검증 쪽이 못 찾으면 ④ 로 간다 — 거절한다. 「안 붙었다」와 「붙었는데 아무 키나 통한다」는 다른
104
+ * 상태이고, 뒤엣것은 사람이 볼 수 없다.
105
+ */
106
+ export function webhookSecretCandidates(lookup) {
107
+ const { purpose, domain, peer, legacyBases = [], allowSinglePeerFallback = false } = lookup;
108
+ const base = WEBHOOK_SECRET_BASE[purpose];
109
+ const out = [
110
+ { name: webhookSecretEnvName(base, domain, peer), pins: 'peer', compatibility: false }
111
+ ];
112
+ /* 검증은 여기서 멈춘다 — 확인한 사람이 명시적으로 열지 않는 한. */
113
+ if (purpose === 'verify' && !allowSinglePeerFallback)
114
+ return out;
115
+ out.push({ name: webhookSecretEnvName(base, domain), pins: 'domain', compatibility: purpose === 'verify' });
116
+ for (const legacy of legacyBases) {
117
+ out.push({ name: webhookSecretEnvName(legacy, domain), pins: 'domain', compatibility: true });
118
+ out.push({ name: legacy, pins: 'installation', compatibility: true });
119
+ }
120
+ return out;
121
+ }
122
+ /**
123
+ * ④ 없을 때 — **거절한다. 그리고 응답에 변수 이름을 넣지 않는다.**
124
+ *
125
+ * 거절 사유는 이미 있다(`WebhookSignatureFailure` 의 `'no-secret'`). 그 사유는 응답에 나가도 된다 —
126
+ * 보내는 쪽이 무엇을 고쳐야 하는지 알아야 한다.
127
+ *
128
+ * **변수 이름은 나가면 안 된다.** 자격을 못 낸 요청에 「이 이름의 변수를 채우면 열린다」고 답하는
129
+ * 것이고, 그 이름이 키 선택 방식과 **어떤 상대가 등록되어 있는지**를 함께 말한다.
130
+ *
131
+ * 그래서 이 문장은 **로그용**이다. 운영하는 사람은 프로세스 로그를 보고, 요청을 보낸 쪽은 못 본다.
132
+ */
133
+ export function webhookSecretMissingNote(candidates) {
134
+ const names = candidates.map(c => c.name).join(' · ');
135
+ return (`비밀값이 없어 거절했다 — 찾아본 이름: ${names}. ` +
136
+ '이 문장은 로그에만 남긴다(응답에 변수 이름을 넣으면 자격 없는 요청에 키 선택 방식과 등록된 상대를 알려 준다).');
137
+ }
138
+ /**
139
+ * 사다리를 실제로 걸어 값을 찾는다 — 환경을 **인자로 받는다.**
140
+ *
141
+ * `process.env` 를 여기서 읽지 않는 이유는 머리말과 같다. 그리고 시험이 값을 손에 들고 걸을 수 있다.
142
+ *
143
+ * 돌려주는 것에 **어느 칸에서 열렸는지**가 있다. `compatibility` 가 참인 칸에서 열렸으면 부르는 쪽이
144
+ * 한 번 알린다 — 조용히 되면 아무도 새 이름으로 옮기지 않는다.
145
+ */
146
+ export function webhookSecretFrom(env, lookup) {
147
+ const candidates = webhookSecretCandidates(lookup);
148
+ for (const candidate of candidates) {
149
+ const value = env[candidate.name]?.trim();
150
+ if (value)
151
+ return { secret: value, opened: candidate };
152
+ }
153
+ return { secret: undefined, tried: candidates };
154
+ }
@@ -72,6 +72,8 @@ __export(index_exports, {
72
72
  UTC_OFFSET: () => UTC_OFFSET,
73
73
  VOCABULARY_EXCEPTIONS: () => VOCABULARY_EXCEPTIONS,
74
74
  VOCABULARY_TYPE: () => VOCABULARY_TYPE,
75
+ WEBHOOK_SECRET_BASE: () => WEBHOOK_SECRET_BASE,
76
+ WEBHOOK_SECRET_NAME_SHAPE: () => WEBHOOK_SECRET_NAME_SHAPE,
75
77
  WEBHOOK_STATUS: () => WEBHOOK_STATUS,
76
78
  WEBHOOK_TOO_MANY: () => WEBHOOK_TOO_MANY,
77
79
  WMS_LOCATION_TYPES: () => WMS_LOCATION_TYPES,
@@ -157,6 +159,7 @@ __export(index_exports, {
157
159
  isPlannedStopStatus: () => isPlannedStopStatus,
158
160
  isResourceKind: () => isResourceKind,
159
161
  isTransformationRecord: () => isTransformationRecord,
162
+ isWebhookSecretSegment: () => isWebhookSecretSegment,
160
163
  isoDurationHours: () => isoDurationHours,
161
164
  itemKeyOf: () => itemKeyOf,
162
165
  judgeAgainstSpec: () => judgeAgainstSpec,
@@ -219,6 +222,10 @@ __export(index_exports, {
219
222
  validateEpcisEvent: () => validateEpcisEvent,
220
223
  validatePerformance: () => validatePerformance,
221
224
  validateScenario: () => validateScenario,
225
+ webhookSecretCandidates: () => webhookSecretCandidates,
226
+ webhookSecretEnvName: () => webhookSecretEnvName,
227
+ webhookSecretFrom: () => webhookSecretFrom,
228
+ webhookSecretMissingNote: () => webhookSecretMissingNote,
222
229
  webhookSenderAction: () => webhookSenderAction,
223
230
  weekdayAt: () => weekdayAt,
224
231
  workingHoursBetween: () => workingHoursBetween,
@@ -4095,6 +4102,55 @@ function checkSequenceRun(lastSeq, seqs) {
4095
4102
  return { accepted, lastSeq: cursor };
4096
4103
  }
4097
4104
 
4105
+ // src/webhook-secret.ts
4106
+ var WEBHOOK_SECRET_BASE = {
4107
+ /** 우리가 서명할 때 — 나가는 쪽. */
4108
+ signing: "WEBHOOK_SIGNING_SECRET",
4109
+ /** 우리가 검증할 때 — 들어오는 쪽. */
4110
+ verify: "WEBHOOK_VERIFY_SECRET"
4111
+ };
4112
+ var WEBHOOK_SECRET_NAME_SHAPE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
4113
+ function isWebhookSecretSegment(segment) {
4114
+ return WEBHOOK_SECRET_NAME_SHAPE.test(segment);
4115
+ }
4116
+ function webhookSecretEnvName(base, domain, peer) {
4117
+ const segments = peer === void 0 ? [domain] : [domain, peer];
4118
+ for (const segment of segments) {
4119
+ if (!isWebhookSecretSegment(segment)) {
4120
+ throw new Error(
4121
+ `webhookSecretEnvName: "${segment}" \uB294 \uBE44\uBC00\uAC12 \uC774\uB984\uC5D0 \uC4F8 \uC218 \uC5C6\uB2E4 \u2014 \uC18C\uBB38\uC790\xB7\uC22B\uC790\uC640 \uC774\uC74C \uD558\uC774\uD508\uB9CC \uC4F4\uB2E4(\uC608: miratek-gunsan). \uBC11\uC904\uC774\uB098 \uB300\uBB38\uC790\uAC00 \uC11E\uC774\uBA74 \uB450 \uC774\uB984\uC774 \uD55C \uD658\uACBD \uBCC0\uC218\uB85C \uB20C\uB9AC\uACE0, \uD55C \uCABD\uC774 \uB2E4\uB978 \uCABD\uC758 \uD0A4\uB85C \uC5F4\uB9B0\uB2E4.`
4122
+ );
4123
+ }
4124
+ }
4125
+ return [base, ...segments.map((s) => s.toUpperCase().replace(/-/g, "_"))].join("__");
4126
+ }
4127
+ function webhookSecretCandidates(lookup) {
4128
+ const { purpose, domain, peer, legacyBases = [], allowSinglePeerFallback = false } = lookup;
4129
+ const base = WEBHOOK_SECRET_BASE[purpose];
4130
+ const out = [
4131
+ { name: webhookSecretEnvName(base, domain, peer), pins: "peer", compatibility: false }
4132
+ ];
4133
+ if (purpose === "verify" && !allowSinglePeerFallback) return out;
4134
+ out.push({ name: webhookSecretEnvName(base, domain), pins: "domain", compatibility: purpose === "verify" });
4135
+ for (const legacy of legacyBases) {
4136
+ out.push({ name: webhookSecretEnvName(legacy, domain), pins: "domain", compatibility: true });
4137
+ out.push({ name: legacy, pins: "installation", compatibility: true });
4138
+ }
4139
+ return out;
4140
+ }
4141
+ function webhookSecretMissingNote(candidates) {
4142
+ const names = candidates.map((c) => c.name).join(" \xB7 ");
4143
+ return `\uBE44\uBC00\uAC12\uC774 \uC5C6\uC5B4 \uAC70\uC808\uD588\uB2E4 \u2014 \uCC3E\uC544\uBCF8 \uC774\uB984: ${names}. \uC774 \uBB38\uC7A5\uC740 \uB85C\uADF8\uC5D0\uB9CC \uB0A8\uAE34\uB2E4(\uC751\uB2F5\uC5D0 \uBCC0\uC218 \uC774\uB984\uC744 \uB123\uC73C\uBA74 \uC790\uACA9 \uC5C6\uB294 \uC694\uCCAD\uC5D0 \uD0A4 \uC120\uD0DD \uBC29\uC2DD\uACFC \uB4F1\uB85D\uB41C \uC0C1\uB300\uB97C \uC54C\uB824 \uC900\uB2E4).`;
4144
+ }
4145
+ function webhookSecretFrom(env, lookup) {
4146
+ const candidates = webhookSecretCandidates(lookup);
4147
+ for (const candidate of candidates) {
4148
+ const value = env[candidate.name]?.trim();
4149
+ if (value) return { secret: value, opened: candidate };
4150
+ }
4151
+ return { secret: void 0, tried: candidates };
4152
+ }
4153
+
4098
4154
  // src/oee.ts
4099
4155
  function computeOee(c, nowMs) {
4100
4156
  const missing = [];
@@ -4452,6 +4508,8 @@ function commandSpecGaps(specs, command) {
4452
4508
  UTC_OFFSET,
4453
4509
  VOCABULARY_EXCEPTIONS,
4454
4510
  VOCABULARY_TYPE,
4511
+ WEBHOOK_SECRET_BASE,
4512
+ WEBHOOK_SECRET_NAME_SHAPE,
4455
4513
  WEBHOOK_STATUS,
4456
4514
  WEBHOOK_TOO_MANY,
4457
4515
  WMS_LOCATION_TYPES,
@@ -4537,6 +4595,7 @@ function commandSpecGaps(specs, command) {
4537
4595
  isPlannedStopStatus,
4538
4596
  isResourceKind,
4539
4597
  isTransformationRecord,
4598
+ isWebhookSecretSegment,
4540
4599
  isoDurationHours,
4541
4600
  itemKeyOf,
4542
4601
  judgeAgainstSpec,
@@ -4599,6 +4658,10 @@ function commandSpecGaps(specs, command) {
4599
4658
  validateEpcisEvent,
4600
4659
  validatePerformance,
4601
4660
  validateScenario,
4661
+ webhookSecretCandidates,
4662
+ webhookSecretEnvName,
4663
+ webhookSecretFrom,
4664
+ webhookSecretMissingNote,
4602
4665
  webhookSenderAction,
4603
4666
  weekdayAt,
4604
4667
  workingHoursBetween,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/ops-contract",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
4
4
  "description": "Operations domain contract — the standard vocabulary that producers and readers agree on (EPCIS 2.0/GS1, ISA-95, IEC 61850/ISO 50001). Types, guards, validation. No state, no engine.",
5
5
  "type": "module",
6
6
  "main": "./dist-cjs/index.cjs",