@operato/ops-contract 0.5.0 → 0.7.0
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/contract.d.ts +50 -0
- package/dist/domain-definition.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/operational-ingest.js +56 -59
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/webhook-signature.d.ts +51 -0
- package/dist/webhook-signature.js +81 -0
- package/dist/webhook.d.ts +129 -0
- package/dist/webhook.js +119 -0
- package/dist-cjs/index.cjs +111 -13
- package/dist-cjs/webhook-signature.cjs +66 -0
- package/package.json +14 -2
package/dist/contract.d.ts
CHANGED
|
@@ -1333,6 +1333,26 @@ export interface ItemState {
|
|
|
1333
1333
|
* 자리의 관측을 속성당 하나만 든 것과 같은 규율이다(§`LocationState.observations`).
|
|
1334
1334
|
*/
|
|
1335
1335
|
testResults?: TestResult[];
|
|
1336
|
+
/**
|
|
1337
|
+
* **부적합 처분** — 이 로트를 어떻게 하기로 정했나(폐기 · 재작업 · 특채 · 보류…).
|
|
1338
|
+
*
|
|
1339
|
+
* ── 이름이 `disposition` 이 아닌 이유 ─────────────────────────────────────
|
|
1340
|
+
* 바로 위의 `disposition` 은 **EPCIS 의 것**이다(`urn:epcglobal:cbv:disp:…` — 이 물품이 지금 어떤
|
|
1341
|
+
* 처지인가). 이것은 **ISA-95 의 것**이다(부적합을 어떻게 할지 사람이 정한 결정). 두 표준이 같은
|
|
1342
|
+
* 낱말을 다른 뜻으로 쓴다.
|
|
1343
|
+
*
|
|
1344
|
+
* 한 이름에 두 뜻을 담으면 어느 표준을 말하는지 읽는 쪽이 알 수 없다. 사건 이름이 이미
|
|
1345
|
+
* `nonconformance.disposition` 이므로 그 앞머리를 쓴다.
|
|
1346
|
+
*
|
|
1347
|
+
* ── 표준에서 왜 여기인가 ──────────────────────────────────────────────────
|
|
1348
|
+
* `MaterialLotType`(B2MML-Material.xsd)이 로트 안에 `Disposition` 을 든다. 시험 결과와 달리 이것은
|
|
1349
|
+
* 표준에서도 로트의 속성이다 — 판정은 대상을 가리키는 별개 기록이고, 처분은 그 대상이 지금 무엇이
|
|
1350
|
+
* 되었나이기 때문이다.
|
|
1351
|
+
*
|
|
1352
|
+
* **마지막 하나만 든다.** 「언제 무엇으로 정했는지」의 이력은 저널이 답하고, 상태가 답하는 것은
|
|
1353
|
+
* 「이 로트가 지금 어떤 것이냐」다. 시험 결과를 명세당 하나만 든 것과 같은 규율이다.
|
|
1354
|
+
*/
|
|
1355
|
+
nonconformance?: DispositionFact;
|
|
1336
1356
|
/** 소속 물류단위(팔레트 SSCC 등) — AggregationEvent 로 맺어진다. 3D 적재 표현의 재료. */
|
|
1337
1357
|
parent?: string;
|
|
1338
1358
|
/**
|
|
@@ -3048,12 +3068,39 @@ export interface AssetStatusDelta extends EffectivePeriod {
|
|
|
3048
3068
|
taskId?: string;
|
|
3049
3069
|
carrying?: string;
|
|
3050
3070
|
}
|
|
3071
|
+
/**
|
|
3072
|
+
* **마감된 설비 상태 구간** — 이 설비가 이 구간 동안 이 상태였다.
|
|
3073
|
+
*
|
|
3074
|
+
* 시점의 전이(`EquipmentStatusDelta`)와 다른 사실이다. 전이는 「지금 이 상태로 바뀌었다」이고 이것은
|
|
3075
|
+
* 「그 구간 동안 그 상태였다」다. 되돌아가 적는 사실이라 사건 시각이 `to` 다.
|
|
3076
|
+
*
|
|
3077
|
+
* 셋을 전이로 담을 수 없다 — 사유가 붙을 자리가 없고(기계가 서는 순간에는 왜 섰는지 아무도 모른다),
|
|
3078
|
+
* 계획·비계획을 나중에 정하며, 전이를 내지 않는 정지(자재 대기 · 작업자 부재)가 많다.
|
|
3079
|
+
*/
|
|
3080
|
+
export interface EquipmentStatePeriodFact {
|
|
3081
|
+
moverId: string;
|
|
3082
|
+
/** `busy` · `setup` · `down` · `idle` · `planned-stop` — ISO 22400 의 시간 모델을 덮는다. */
|
|
3083
|
+
status: string;
|
|
3084
|
+
from: ISOTime;
|
|
3085
|
+
to: ISOTime;
|
|
3086
|
+
/** 사람이 정한 사유. 없을 수 있다 — 적지 않은 것과 사유가 없는 것은 다르므로 지어내지 않는다. */
|
|
3087
|
+
reasonCode?: string;
|
|
3088
|
+
decidedBy?: string;
|
|
3089
|
+
recordTime?: ISOTime;
|
|
3090
|
+
}
|
|
3051
3091
|
/** 품질 산출 델타 — recordOutput(양품/불량) 시 방출. goodCount/scrapCount 는 설비 누적값. */
|
|
3052
3092
|
export interface QualityDelta {
|
|
3053
3093
|
moverId: string;
|
|
3054
3094
|
good: boolean;
|
|
3055
3095
|
goodCount: number;
|
|
3056
3096
|
scrapCount: number;
|
|
3097
|
+
/**
|
|
3098
|
+
* 이 산출이 어느 작업의 것인가 — **귀속이고, 수량의 뜻을 바꾸지 않는다**(설비 누적 그대로).
|
|
3099
|
+
*
|
|
3100
|
+
* 현장에서 실적을 올리는 단위는 작업인데 이 갈래의 정체는 설비다. 귀속이 없으면 두 사실이 만나는
|
|
3101
|
+
* 자리가 없어 「이 작업에서 몇 개 나왔나」를 저널에서 되짚을 수 없다.
|
|
3102
|
+
*/
|
|
3103
|
+
taskId?: string;
|
|
3057
3104
|
}
|
|
3058
3105
|
export interface TaskStatusDelta {
|
|
3059
3106
|
taskId: string;
|
|
@@ -3256,6 +3303,8 @@ export interface TwinModelDef {
|
|
|
3256
3303
|
/** parallelism = 동시 처리 수(LocationState.parallelism 참조). capacity 는 저장 용량. */
|
|
3257
3304
|
locations: (EffectivePeriod & {
|
|
3258
3305
|
id: string;
|
|
3306
|
+
name?: string;
|
|
3307
|
+
gs1Id?: string;
|
|
3259
3308
|
type: string;
|
|
3260
3309
|
capacity: number;
|
|
3261
3310
|
parallelism?: number;
|
|
@@ -3269,6 +3318,7 @@ export interface TwinModelDef {
|
|
|
3269
3318
|
*/
|
|
3270
3319
|
equipment: (EffectivePeriod & {
|
|
3271
3320
|
id: string;
|
|
3321
|
+
name?: string;
|
|
3272
3322
|
kind: string;
|
|
3273
3323
|
homeLocation: string;
|
|
3274
3324
|
identity?: string;
|
|
@@ -133,6 +133,7 @@ export interface DurationVariability {
|
|
|
133
133
|
export interface OperationDef extends EffectivePeriod {
|
|
134
134
|
key: string;
|
|
135
135
|
label: string;
|
|
136
|
+
name?: string;
|
|
136
137
|
intent: OperationIntent;
|
|
137
138
|
/** 오퍼레이션이 수행되는 자리 타입(LocationTypeDef.key). 커널이 locationByType 로 위치 해소. */
|
|
138
139
|
locationType?: string;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -38,5 +38,7 @@ export * from "./vocabulary.js";
|
|
|
38
38
|
export * from "./wms-profile.js";
|
|
39
39
|
export * from "./yms-profile.js";
|
|
40
40
|
export * from "./canonical-record.js";
|
|
41
|
+
/* `webhook-signature.ts` 는 여기 없다 — `node:crypto` 를 쓰므로 `@operato/ops-contract/webhook` 으로만 나간다. */
|
|
42
|
+
export * from "./webhook.js";
|
|
41
43
|
export * from "./version.js";
|
|
42
44
|
export * from "./oee.js";
|
|
@@ -60,6 +60,8 @@ const ASSET_STATUS = ['idle', 'in-use'];
|
|
|
60
60
|
const SPECS = {
|
|
61
61
|
task: {
|
|
62
62
|
eventType: OP_EVENT.task,
|
|
63
|
+
match: ['taskId'],
|
|
64
|
+
matchOrder: 50,
|
|
63
65
|
identity: 'taskId',
|
|
64
66
|
/* 종류가 없으면 성과를 종류별로 모을 수 없고(선언된 시간·수율이 종류로 붙는다) 지어낼 수도 없다. */
|
|
65
67
|
required: ['taskId', 'kind', 'status'],
|
|
@@ -79,6 +81,8 @@ const SPECS = {
|
|
|
79
81
|
},
|
|
80
82
|
equipment: {
|
|
81
83
|
eventType: OP_EVENT.equipment,
|
|
84
|
+
match: ['moverId'],
|
|
85
|
+
matchOrder: 20,
|
|
82
86
|
identity: 'moverId', // vocabulary-guard: allow 저널 와이어 필드(델타의 이름이 계약이다)
|
|
83
87
|
required: ['moverId', 'kind', 'status'], // vocabulary-guard: allow 위와 같은 이유
|
|
84
88
|
fields: {
|
|
@@ -94,6 +98,8 @@ const SPECS = {
|
|
|
94
98
|
},
|
|
95
99
|
person: {
|
|
96
100
|
eventType: OP_EVENT.person,
|
|
101
|
+
match: ['personId'],
|
|
102
|
+
matchOrder: 30,
|
|
97
103
|
identity: 'personId',
|
|
98
104
|
required: ['personId', 'status'],
|
|
99
105
|
fields: {
|
|
@@ -104,6 +110,8 @@ const SPECS = {
|
|
|
104
110
|
},
|
|
105
111
|
asset: {
|
|
106
112
|
eventType: OP_EVENT.asset,
|
|
113
|
+
match: ['assetId'],
|
|
114
|
+
matchOrder: 40,
|
|
107
115
|
identity: 'assetId',
|
|
108
116
|
required: ['assetId', 'status'],
|
|
109
117
|
fields: {
|
|
@@ -114,6 +122,8 @@ const SPECS = {
|
|
|
114
122
|
},
|
|
115
123
|
order: {
|
|
116
124
|
eventType: OP_EVENT.order,
|
|
125
|
+
match: ['orderId'],
|
|
126
|
+
matchOrder: 60,
|
|
117
127
|
identity: 'orderId',
|
|
118
128
|
/*
|
|
119
129
|
* 요청량·이행량을 **함께** 받는다. 없으면 리듀서가 진척을 0 으로 적는데(`requested ? … : 0`),
|
|
@@ -129,10 +139,21 @@ const SPECS = {
|
|
|
129
139
|
},
|
|
130
140
|
quality: {
|
|
131
141
|
eventType: OP_EVENT.quality,
|
|
142
|
+
match: ['moverId', 'good'],
|
|
143
|
+
matchOrder: 15,
|
|
132
144
|
identity: 'moverId', // vocabulary-guard: allow 저널 와이어 필드
|
|
133
145
|
/* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
|
|
134
146
|
required: ['moverId', 'good', 'goodCount', 'scrapCount'], // vocabulary-guard: allow
|
|
135
|
-
|
|
147
|
+
/*
|
|
148
|
+
* `taskId` 는 **귀속**이다 — 이 산출이 어느 작업의 것인가. 수량의 뜻은 바뀌지 않는다(설비 누적).
|
|
149
|
+
*
|
|
150
|
+
* 왜 필요한가: 작업자가 올리는 실적은 작업 단위인데 이 갈래의 정체는 설비뿐이라, 두 사실이 만나는
|
|
151
|
+
* 자리가 없었다. 귀속이 없으면 「이 작업에서 몇 개 나왔나」를 저널에서 되짚을 방법이 없다.
|
|
152
|
+
*
|
|
153
|
+
* 정체를 `taskId` 로 바꾸지 않는 이유는 OEE 가 설비 단위여서다. 정체를 옮기면 설비 누적을 셀 수
|
|
154
|
+
* 없어진다.
|
|
155
|
+
*/
|
|
156
|
+
fields: { moverId: 'string', good: 'boolean', goodCount: 'number', scrapCount: 'number', taskId: 'string', recordTime: 'string' } // vocabulary-guard: allow
|
|
136
157
|
},
|
|
137
158
|
/*
|
|
138
159
|
* **시험 결과** — 대상을 가리켜 들어온다(표준 `TestResult.TestableObjectID`).
|
|
@@ -170,6 +191,8 @@ const SPECS = {
|
|
|
170
191
|
*/
|
|
171
192
|
'equipment-period': {
|
|
172
193
|
eventType: OP_EVENT.equipmentPeriod,
|
|
194
|
+
match: ['moverId', 'status', 'from', 'to'],
|
|
195
|
+
matchOrder: 10,
|
|
173
196
|
identity: 'moverId', // vocabulary-guard: allow 저널 와이어 필드 — 전이와 같은 이름을 쓴다
|
|
174
197
|
required: ['moverId', 'status', 'from', 'to'], // vocabulary-guard: allow 위와 같은 이유
|
|
175
198
|
fields: {
|
|
@@ -196,6 +219,8 @@ const SPECS = {
|
|
|
196
219
|
*/
|
|
197
220
|
disposition: {
|
|
198
221
|
eventType: OP_EVENT.disposition,
|
|
222
|
+
match: ['subjectId', 'decision'],
|
|
223
|
+
matchOrder: 80,
|
|
199
224
|
identity: 'subjectId',
|
|
200
225
|
/* 무엇을 어떻게 하기로 했나 — 둘 중 하나가 없으면 그 결정은 아무 데도 붙지 못한다. */
|
|
201
226
|
required: ['subjectId', 'decision'],
|
|
@@ -207,6 +232,8 @@ const SPECS = {
|
|
|
207
232
|
},
|
|
208
233
|
test: {
|
|
209
234
|
eventType: OP_EVENT.test,
|
|
235
|
+
match: ['testableObjectId'],
|
|
236
|
+
matchOrder: 70,
|
|
210
237
|
identity: 'testableObjectId',
|
|
211
238
|
/* 무엇을 어느 기준으로 시험했나 — 둘 중 하나가 없으면 그 결과는 아무 데도 붙지 못한다. */
|
|
212
239
|
required: ['testableObjectId', 'specId'],
|
|
@@ -234,12 +261,16 @@ const SPECS = {
|
|
|
234
261
|
*/
|
|
235
262
|
complete: {
|
|
236
263
|
eventType: OP_EVENT.complete,
|
|
264
|
+
match: ['completeAxis'],
|
|
265
|
+
matchOrder: 100,
|
|
237
266
|
identity: 'completeAxis',
|
|
238
267
|
required: ['completeAxis', 'since'],
|
|
239
268
|
fields: { completeAxis: 'string', since: 'string', recordTime: 'string' }
|
|
240
269
|
},
|
|
241
270
|
observation: {
|
|
242
271
|
eventType: OP_EVENT.observation,
|
|
272
|
+
match: ['locationId', 'propertyId'],
|
|
273
|
+
matchOrder: 90,
|
|
243
274
|
identity: 'locationId',
|
|
244
275
|
/* 자리와 속성 — 둘 중 하나가 없으면 그 관측은 아무 데도 붙지 못한다. */
|
|
245
276
|
required: ['locationId', 'propertyId'],
|
|
@@ -268,68 +299,34 @@ export function operationalKindOf(record) {
|
|
|
268
299
|
if (!record || typeof record !== 'object')
|
|
269
300
|
return undefined;
|
|
270
301
|
const r = record;
|
|
271
|
-
if (r.epc !== undefined || r.meterId !== undefined || r.equipmentId !== undefined)
|
|
272
|
-
return undefined;
|
|
273
|
-
const has = (k) => typeof r[k] === 'string' && r[k].trim().length > 0;
|
|
274
|
-
/* vocabulary-guard: allow 저널 와이어 필드로 가른다 */
|
|
275
|
-
/*
|
|
276
|
-
* 구간이 있으면 **마감된 상태 구간**이지 시점의 전이가 아니다. 전이보다 먼저 본다 — 뒤에 두면
|
|
277
|
-
* 사람이 되돌아가 적은 구간이 「지금 이 상태다」로 읽혀 트윈의 상태가 과거로 끌린다.
|
|
278
|
-
*/
|
|
279
|
-
if (has('moverId') && has('from') && has('to'))
|
|
280
|
-
return 'equipment-period';
|
|
281
|
-
if (has('moverId'))
|
|
282
|
-
return r.good !== undefined ? 'quality' : 'equipment';
|
|
283
|
-
if (has('personId'))
|
|
284
|
-
return 'person';
|
|
285
|
-
if (has('assetId'))
|
|
286
|
-
return 'asset';
|
|
287
|
-
if (has('taskId'))
|
|
288
|
-
return 'task'; // 작업이 든 `orderId` 는 소속(참조)이다
|
|
289
|
-
if (has('orderId'))
|
|
290
|
-
return 'order';
|
|
291
|
-
/*
|
|
292
|
-
* ── ★ **채널을 열고 들어오는 길을 내지 않았다** (2026-08-24) ─────────────────
|
|
293
|
-
* `OP_EVENT.test` 를 계약에 냈는데 이 라우팅이 `testableObjectId` 를 보지 않았다. 그래서 커넥터의
|
|
294
|
-
* 시험 결과가 **어느 통도 아니어서 조용히 버려졌다** — 거부 목록에도 남지 않았다(운영 경로를 아예
|
|
295
|
-
* 지나지 않으므로).
|
|
296
|
-
*
|
|
297
|
-
* 같은 부류가 하루에 세 번 났다: `ilmd`(매핑에 자리 없음) · 사건 시각(이름 어긋남) · 그리고 이것.
|
|
298
|
-
* **계약에 자리를 만드는 것과 그 자리로 가는 길을 내는 것은 다른 일이다.** 앞의 것만 하면 보내는
|
|
299
|
-
* 쪽에는 「실었다」로 보이고 화면에는 「없다」로 보인다.
|
|
300
|
-
*
|
|
301
|
-
* 순서상 뒤에 둔다 — 시험 결과가 작업·오더를 함께 가리킬 수 있고, 그때 그것은 **그 작업의 사실**이다.
|
|
302
|
-
*/
|
|
303
|
-
if (has('testableObjectId'))
|
|
304
|
-
return 'test';
|
|
305
302
|
/*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
* 시험 결과 뒤에 둔다 — 처분은 판정을 가리킬 수 있고(`specId`), 그때 정체성은 처분 대상이지
|
|
310
|
-
* 시험 대상이 아니다. 앞에 두면 판정에 딸린 처분이 시험으로 읽힌다.
|
|
311
|
-
*/
|
|
312
|
-
if (has('subjectId') && has('decision'))
|
|
313
|
-
return 'disposition';
|
|
314
|
-
/*
|
|
315
|
-
* ── ★ **채널을 열고 또 길을 내지 않았다** (2026-08-24) ──────────────────────
|
|
316
|
-
* `OP_EVENT.observation`(`location.measured`)을 내고 상태(`LocationState.observations`)와 조회
|
|
317
|
-
* (`observationAt`)까지 붙였는데 **이 라우팅이 `locationId` 를 보지 않았다.** 커넥터가 방의 온습도를
|
|
318
|
-
* 실어 보내면 「어느 운영 사실인지 모른다」로 거부됐다.
|
|
319
|
-
*
|
|
320
|
-
* 같은 부류를 하루에 일곱 번 만났다. 다만 이번엔 **거부되고 이유가 남았다** — 시험 결과 때는 어느
|
|
321
|
-
* 통도 아니어서 조용히 사라졌다. 그 차이가 이것을 5분 만에 찾게 했다(§`isOperationalRecord`).
|
|
322
|
-
*
|
|
323
|
-
* **둘을 함께 요구한다.** `locationId` 만으로는 자리를 말하는 다른 사실과 섞인다. 관측은 「어느
|
|
324
|
-
* 자리의 **무엇**을 쟀나」이므로 속성 없이는 담을 곳이 없다 — 그때는 받지 않는 것이 옳다.
|
|
303
|
+
* EPCIS·에너지와 겹치지 않게 본다: `epc`·`meterId` 가 있으면 그쪽 어휘이고, `equipmentId` 는 설비
|
|
304
|
+
* **에너지** 상태의 이름이다(운영 설비는 `moverId`).
|
|
325
305
|
*/
|
|
326
|
-
if (
|
|
327
|
-
return
|
|
328
|
-
/*
|
|
329
|
-
|
|
330
|
-
|
|
306
|
+
if (r.epc !== undefined || r.meterId !== undefined || r.equipmentId !== undefined)
|
|
307
|
+
return undefined;
|
|
308
|
+
/* 문자열은 비어 있으면 없는 것으로 본다 — 공백만 실은 필드는 말한 것이 아니다. */
|
|
309
|
+
const present = (k) => {
|
|
310
|
+
const v = r[k];
|
|
311
|
+
if (v === undefined || v === null)
|
|
312
|
+
return false;
|
|
313
|
+
return typeof v === 'string' ? v.trim().length > 0 : true;
|
|
314
|
+
};
|
|
315
|
+
for (const kind of MATCH_ORDER) {
|
|
316
|
+
const spec = SPECS[kind];
|
|
317
|
+
const fields = spec.match ?? [spec.identity];
|
|
318
|
+
if (fields.every(present))
|
|
319
|
+
return kind;
|
|
320
|
+
}
|
|
331
321
|
return undefined;
|
|
332
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* 판정 순서 — 표의 `matchOrder` 에서 한 번만 만든다.
|
|
325
|
+
*
|
|
326
|
+
* 좁은 것이 먼저다. 그리고 자원(설비·사람·자산)을 작업보다, 작업을 오더보다 먼저 본다 — 정체 필드는
|
|
327
|
+
* 하나만 오지 않고, 아무거나 먼저 보면 **참조를 주체로 읽는다**(설비 델타가 든 `taskId` 는 소속이다).
|
|
328
|
+
*/
|
|
329
|
+
const MATCH_ORDER = Object.keys(SPECS).sort((a, b) => (SPECS[a].matchOrder ?? 1000) - (SPECS[b].matchOrder ?? 1000));
|
|
333
330
|
/** 이 레코드가 운영 사실인가 — 호스트의 라우팅이 묻는 자리. */
|
|
334
331
|
export function isOperationalRecord(record) {
|
|
335
332
|
return operationalKindOf(record) !== undefined;
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CONTRACT_VERSION = "0.
|
|
1
|
+
export declare const CONTRACT_VERSION = "0.7.0";
|
package/dist/version.js
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 헤더 이름.
|
|
3
|
+
*
|
|
4
|
+
* `mes` 가 아니라 `ops` 인 이유는 이 계약이 한 제품의 것이 아니기 때문이다 — operato-wms 가 같은
|
|
5
|
+
* 아웃박스를 쓰게 되어 있고, 커넥터도 제품을 가리지 않는다. 패키지 이름에 `twin` 이 없는 것과 같은
|
|
6
|
+
* 이유다.
|
|
7
|
+
*/
|
|
8
|
+
export declare const WEBHOOK_HEADER: {
|
|
9
|
+
/** 보내는 쪽이 누구인가. 사람이 로그에서 읽는 용도이고, 어디로 갈지는 주소가 정한다. */
|
|
10
|
+
readonly source: "x-ops-source";
|
|
11
|
+
/** 서명에 들어간 시각(RFC 3339). */
|
|
12
|
+
readonly timestamp: "x-ops-timestamp";
|
|
13
|
+
/** HMAC-SHA256 hex. */
|
|
14
|
+
readonly signature: "x-ops-signature";
|
|
15
|
+
};
|
|
16
|
+
/** 서명 창 기본값 — 5분. 양쪽 시계 차이와 재전송을 함께 감당하는 폭이다. */
|
|
17
|
+
export declare const WEBHOOK_TOLERANCE_MS: number;
|
|
18
|
+
/**
|
|
19
|
+
* 서명 대상 문자열 — **시각과 본문을 점 하나로 잇는다.**
|
|
20
|
+
*
|
|
21
|
+
* 본문만 서명하면 같은 요청을 나중에 그대로 다시 보낼 수 있다. 시각을 함께 서명해야 창 밖의 요청을
|
|
22
|
+
* 거절할 수 있다.
|
|
23
|
+
*/
|
|
24
|
+
export declare function webhookSigningInput(timestamp: string, body: string): string;
|
|
25
|
+
/** 서명을 만든다 — hex. */
|
|
26
|
+
export declare function signWebhook(secret: string, timestamp: string, body: string): string;
|
|
27
|
+
/** 서명이 맞지 않는 이유 — 401 을 받은 쪽이 무엇을 고쳐야 하는지 알 수 있게 나눈다. */
|
|
28
|
+
export type WebhookSignatureFailure = 'no-secret' | 'no-signature' | 'no-timestamp' | 'bad-timestamp' | 'expired' | 'mismatch';
|
|
29
|
+
export type WebhookSignatureVerdict = {
|
|
30
|
+
ok: true;
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
reason: WebhookSignatureFailure;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* 서명을 확인한다.
|
|
37
|
+
*
|
|
38
|
+
* `nowMs` 를 인자로 받는다 — 안에서 현재 시각을 읽으면 시험이 시계에 매인다.
|
|
39
|
+
*
|
|
40
|
+
* **본문은 받은 바이트 그대로여야 한다.** 파싱한 객체를 다시 문자열로 만들면 키 순서와 공백이 달라져
|
|
41
|
+
* 서명이 맞지 않는다. 받은 바이트를 들고 있지 않으면 확인할 수 없고, 그때는 확인한 척하지 말고
|
|
42
|
+
* 거절해야 한다.
|
|
43
|
+
*/
|
|
44
|
+
export declare function verifyWebhookSignature(args: {
|
|
45
|
+
secret: string;
|
|
46
|
+
timestamp: string;
|
|
47
|
+
signature: string;
|
|
48
|
+
body: string;
|
|
49
|
+
nowMs: number;
|
|
50
|
+
toleranceMs?: number;
|
|
51
|
+
}): WebhookSignatureVerdict;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **밀어 주는 연동의 서명** — 보내는 쪽과 받는 쪽이 같은 문자열에 서명해야 한다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 계약에 있나 (2026-08-31) ──────────────────────────────────────────
|
|
5
|
+
* 서명은 양쪽이 **한 글자까지 같은 규칙**을 써야 성립한다. 한쪽이 이어붙이는 순서를 바꾸면 다른 쪽은
|
|
6
|
+
* 401 만 보고 이유를 알 수 없다. 그래서 만드는 함수와 확인하는 함수가 한 파일에 있어야 한다.
|
|
7
|
+
*
|
|
8
|
+
* 실제로 operato-mes 안에 `sign` 과 `verifySignature` 가 함께 있었는데, `verifySignature` 는 저장소
|
|
9
|
+
* 어디에서도 불리지 않았다 — 확인할 수신부가 없었기 때문이다. 확인하는 쪽이 생기면서 그 함수의 자리가
|
|
10
|
+
* 여기가 됐다.
|
|
11
|
+
*
|
|
12
|
+
* ── 왜 색인에 없나 ───────────────────────────────────────────────────────
|
|
13
|
+
* `node:crypto` 를 쓴다. 색인(`index.ts`)에 실으면 이 패키지를 쓰는 브라우저 번들이 `node:crypto` 를
|
|
14
|
+
* 묶으려다 실패한다. 그래서 `@operato/ops-contract/webhook` 으로만 나간다.
|
|
15
|
+
*
|
|
16
|
+
* ── 왜 평문 비밀값만으로는 부족한가 ─────────────────────────────────────
|
|
17
|
+
* 헤더에 비밀값을 그대로 실으면 그 요청을 그대로 다시 보내는 것을 막을 수 없다. 시각을 서명에 넣고
|
|
18
|
+
* 창 밖의 시각을 거절하면 그 재사용이 막힌다.
|
|
19
|
+
*/
|
|
20
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
21
|
+
/**
|
|
22
|
+
* 헤더 이름.
|
|
23
|
+
*
|
|
24
|
+
* `mes` 가 아니라 `ops` 인 이유는 이 계약이 한 제품의 것이 아니기 때문이다 — operato-wms 가 같은
|
|
25
|
+
* 아웃박스를 쓰게 되어 있고, 커넥터도 제품을 가리지 않는다. 패키지 이름에 `twin` 이 없는 것과 같은
|
|
26
|
+
* 이유다.
|
|
27
|
+
*/
|
|
28
|
+
export const WEBHOOK_HEADER = {
|
|
29
|
+
/** 보내는 쪽이 누구인가. 사람이 로그에서 읽는 용도이고, 어디로 갈지는 주소가 정한다. */
|
|
30
|
+
source: 'x-ops-source',
|
|
31
|
+
/** 서명에 들어간 시각(RFC 3339). */
|
|
32
|
+
timestamp: 'x-ops-timestamp',
|
|
33
|
+
/** HMAC-SHA256 hex. */
|
|
34
|
+
signature: 'x-ops-signature'
|
|
35
|
+
};
|
|
36
|
+
/** 서명 창 기본값 — 5분. 양쪽 시계 차이와 재전송을 함께 감당하는 폭이다. */
|
|
37
|
+
export const WEBHOOK_TOLERANCE_MS = 5 * 60_000;
|
|
38
|
+
/**
|
|
39
|
+
* 서명 대상 문자열 — **시각과 본문을 점 하나로 잇는다.**
|
|
40
|
+
*
|
|
41
|
+
* 본문만 서명하면 같은 요청을 나중에 그대로 다시 보낼 수 있다. 시각을 함께 서명해야 창 밖의 요청을
|
|
42
|
+
* 거절할 수 있다.
|
|
43
|
+
*/
|
|
44
|
+
export function webhookSigningInput(timestamp, body) {
|
|
45
|
+
return `${timestamp}.${body}`;
|
|
46
|
+
}
|
|
47
|
+
/** 서명을 만든다 — hex. */
|
|
48
|
+
export function signWebhook(secret, timestamp, body) {
|
|
49
|
+
return createHmac('sha256', secret).update(webhookSigningInput(timestamp, body)).digest('hex');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 서명을 확인한다.
|
|
53
|
+
*
|
|
54
|
+
* `nowMs` 를 인자로 받는다 — 안에서 현재 시각을 읽으면 시험이 시계에 매인다.
|
|
55
|
+
*
|
|
56
|
+
* **본문은 받은 바이트 그대로여야 한다.** 파싱한 객체를 다시 문자열로 만들면 키 순서와 공백이 달라져
|
|
57
|
+
* 서명이 맞지 않는다. 받은 바이트를 들고 있지 않으면 확인할 수 없고, 그때는 확인한 척하지 말고
|
|
58
|
+
* 거절해야 한다.
|
|
59
|
+
*/
|
|
60
|
+
export function verifyWebhookSignature(args) {
|
|
61
|
+
const { secret, timestamp, signature, body, nowMs } = args;
|
|
62
|
+
const toleranceMs = args.toleranceMs ?? WEBHOOK_TOLERANCE_MS;
|
|
63
|
+
if (!secret)
|
|
64
|
+
return { ok: false, reason: 'no-secret' };
|
|
65
|
+
if (!signature)
|
|
66
|
+
return { ok: false, reason: 'no-signature' };
|
|
67
|
+
if (!timestamp)
|
|
68
|
+
return { ok: false, reason: 'no-timestamp' };
|
|
69
|
+
const at = Date.parse(timestamp);
|
|
70
|
+
if (Number.isNaN(at))
|
|
71
|
+
return { ok: false, reason: 'bad-timestamp' };
|
|
72
|
+
/* 양쪽 모두를 본다 — 미래로 적힌 시각도 창 밖이면 거절한다. */
|
|
73
|
+
if (Math.abs(nowMs - at) > toleranceMs)
|
|
74
|
+
return { ok: false, reason: 'expired' };
|
|
75
|
+
const expected = Buffer.from(signWebhook(secret, timestamp, body), 'utf-8');
|
|
76
|
+
const given = Buffer.from(signature, 'utf-8');
|
|
77
|
+
/* 길이가 다르면 `timingSafeEqual` 이 던진다 — 길이 자체는 비밀이 아니므로 먼저 본다. */
|
|
78
|
+
if (expected.length !== given.length)
|
|
79
|
+
return { ok: false, reason: 'mismatch' };
|
|
80
|
+
return timingSafeEqual(expected, given) ? { ok: true } : { ok: false, reason: 'mismatch' };
|
|
81
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 응답 코드 — **보내는 쪽이 무엇을 할지가 코드에서 나온다.**
|
|
3
|
+
*
|
|
4
|
+
* 코드마다 보내는 쪽의 행동이 하나로 정해져야 한다. 두 뜻을 한 코드에 담으면 보내는 쪽은 본문을 읽고
|
|
5
|
+
* 갈라야 하고, 본문 모양은 원본마다 다르다.
|
|
6
|
+
*
|
|
7
|
+
* 200 받아서 반영했다
|
|
8
|
+
* 400 본문을 레코드로 옮길 수 없다 그쪽 모양이 바뀐 것이다
|
|
9
|
+
* 401 비밀값 또는 서명이 맞지 않는다
|
|
10
|
+
* 404 모르는 연결이거나 모르는 트윈
|
|
11
|
+
* 409 번호가 비었다 `expectedSeq` 부터 다시 보낸다
|
|
12
|
+
* 422 일부가 검증에서 떨어졌다 떨어진 것만 따로 두고 나머지는 보냄으로 표시한다
|
|
13
|
+
* 500 받는 쪽 오류 다시 보내면 될 수 있다
|
|
14
|
+
* 501 그 커넥터가 밀어 주기를 받을 줄 모른다
|
|
15
|
+
* 503 그 트윈이 지금 실시간으로 돌지 않는다 들고 기다렸다가 다시 보낸다
|
|
16
|
+
*
|
|
17
|
+
* **503 인 이유.** 예전에는 409 였고 「재시도해도 같은 것」으로 분류했다. 그런데 트윈이 안 도는 것은
|
|
18
|
+
* 누가 띄우면 풀린다. 4xx 는 「보낸 요청이 잘못됐다」는 뜻이라, 그것을 받은 쪽이 포기하면 그 사실이
|
|
19
|
+
* 사라진다. 보내는 쪽이 해야 할 일은 사실을 들고 기다렸다 다시 보내는 것이므로 5xx 다.
|
|
20
|
+
*/
|
|
21
|
+
export declare const WEBHOOK_STATUS: {
|
|
22
|
+
readonly ok: 200;
|
|
23
|
+
readonly badPayload: 400;
|
|
24
|
+
readonly badSecret: 401;
|
|
25
|
+
readonly unknownTarget: 404;
|
|
26
|
+
readonly sequenceGap: 409;
|
|
27
|
+
readonly partial: 422;
|
|
28
|
+
readonly failed: 500;
|
|
29
|
+
readonly unsupported: 501;
|
|
30
|
+
readonly notLive: 503;
|
|
31
|
+
};
|
|
32
|
+
export type WebhookStatus = (typeof WEBHOOK_STATUS)[keyof typeof WEBHOOK_STATUS];
|
|
33
|
+
/**
|
|
34
|
+
* 그 응답을 받은 뒤 보내는 쪽이 할 일.
|
|
35
|
+
*
|
|
36
|
+
* accepted 보냄으로 표시한다(`lastSeq` 까지)
|
|
37
|
+
* partial `lastSeq` 까지 보냄으로 표시하되, 응답이 든 `rejected` 는 따로 둔다. 다시 보내지 않는다
|
|
38
|
+
* resend-from `expectedSeq` 부터 다시 보낸다
|
|
39
|
+
* wait 그대로 들고 쉬었다가 다시 보낸다. 버리지 않는다
|
|
40
|
+
* retry 다시 보낸다. 받는 쪽 오류다
|
|
41
|
+
* stop 보내지 말고 사람을 부른다. 다시 보내도 같다
|
|
42
|
+
*/
|
|
43
|
+
export type WebhookSenderAction = 'accepted' | 'partial' | 'resend-from' | 'wait' | 'retry' | 'stop';
|
|
44
|
+
/**
|
|
45
|
+
* 응답 코드 → 보내는 쪽의 행동. **모르는 코드도 답을 낸다.**
|
|
46
|
+
*
|
|
47
|
+
* 표에 없는 코드가 오는 일이 있다(프록시의 502, 게이트웨이의 504). 그때 「모른다」로 두면 보내는 쪽이
|
|
48
|
+
* 각자 정하고, 한 곳은 버리고 다른 곳은 되풀이한다. 5xx 는 받는 쪽 사정이므로 다시 보내고, 나머지는
|
|
49
|
+
* 보낸 것이 잘못이므로 멈춘다.
|
|
50
|
+
*/
|
|
51
|
+
export declare function webhookSenderAction(status: number): WebhookSenderAction;
|
|
52
|
+
/** 429 를 함께 쓰는 곳이 있다 — 받는 쪽이 따라가지 못한다는 뜻이고, 행동은 503 과 같다. */
|
|
53
|
+
export declare const WEBHOOK_TOO_MANY = 429;
|
|
54
|
+
/**
|
|
55
|
+
* 봉투 하나 — 번호와 사실을 함께 든다.
|
|
56
|
+
*
|
|
57
|
+
* `scope` 가 **번호를 매기는 단위**다. 이것이 없으면 받는 쪽은 그 번호가 어느 줄의 번호인지 모르고,
|
|
58
|
+
* 커서를 무엇으로 잡을지 짐작해야 한다. 실제로 operato-mes 가 `[domain, channel]` 마다 번호를 매기면서
|
|
59
|
+
* 봉투에는 `channel` 을 싣지 않고 있었다 — 두 채널이 흐르기 시작하면 두 줄의 번호가 한 커서에 섞여
|
|
60
|
+
* 연속성 검사가 끊임없이 「비었다」로 답한다.
|
|
61
|
+
*
|
|
62
|
+
* **주소에서 유추하지 않는다.** 「이 문으로 왔으니 이 단위겠지」로 잡으면, 같은 문에 두 번째 단위를
|
|
63
|
+
* 붙이는 날 오류 없이 연속성이 깨진다. 주소는 어디로 갈지를 정하고 `scope` 는 어느 줄인지를 정한다.
|
|
64
|
+
*/
|
|
65
|
+
export interface WebhookEnvelope {
|
|
66
|
+
/** 이 단위 안에서 1씩 증가한다. */
|
|
67
|
+
seq: number;
|
|
68
|
+
/** 번호를 매기는 단위의 이름. 보내는 쪽이 정하고 받는 쪽은 열쇠로만 다룬다. */
|
|
69
|
+
scope: string;
|
|
70
|
+
/** 멱등 키. 같은 값이 두 번 와도 결과가 같아야 한다. */
|
|
71
|
+
eventId?: string;
|
|
72
|
+
/** 사실이 일어난 시각. */
|
|
73
|
+
eventTime?: string;
|
|
74
|
+
/** 보내는 쪽에 기록된 시각. `eventTime` 과 구별한다. */
|
|
75
|
+
recordTime?: string;
|
|
76
|
+
/** 정규 레코드 하나. */
|
|
77
|
+
record: unknown;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 번호 판정 — **네 가지이고, 합치지 않는다.**
|
|
81
|
+
*
|
|
82
|
+
* first 이 단위의 첫 봉투다. 받는 쪽에 커서가 없다
|
|
83
|
+
* next 바로 다음 번호다
|
|
84
|
+
* behind 이미 본 번호다. 재전송이다
|
|
85
|
+
* gap 건너뛰었다. `expectedSeq` 부터 다시 받아야 한다
|
|
86
|
+
*
|
|
87
|
+
* `first` 를 `next` 와 나누는 이유는 **아무 번호나 받아들인 것**이기 때문이다. 커서가 없으면 앞에
|
|
88
|
+
* 무엇이 있었는지 알 방법이 없으므로 거절할 근거도 없다. 그것을 `next` 로 뭉치면 「이어받았다」와
|
|
89
|
+
* 「처음부터 시작했다」가 같아 보이고, 커서를 잃은 재기동이 정상으로 읽힌다.
|
|
90
|
+
*
|
|
91
|
+
* `behind` 를 오류로 만들지 않는 이유는 재전송이 이 방식의 정상 동작이기 때문이다. 같은 사실이 두 번
|
|
92
|
+
* 들어오는 것은 유입 경계가 거른다.
|
|
93
|
+
*/
|
|
94
|
+
export type SequenceVerdict = {
|
|
95
|
+
kind: 'first';
|
|
96
|
+
lastSeq: number;
|
|
97
|
+
} | {
|
|
98
|
+
kind: 'next';
|
|
99
|
+
lastSeq: number;
|
|
100
|
+
} | {
|
|
101
|
+
kind: 'behind';
|
|
102
|
+
lastSeq: number;
|
|
103
|
+
} | {
|
|
104
|
+
kind: 'gap';
|
|
105
|
+
lastSeq: number;
|
|
106
|
+
expectedSeq: number;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* 번호를 이어 볼 수 있는가.
|
|
110
|
+
*
|
|
111
|
+
* @param lastSeq 이 단위에서 마지막으로 받아들인 번호. 받은 적이 없으면 `undefined`
|
|
112
|
+
* @param seq 이번 봉투의 번호
|
|
113
|
+
*/
|
|
114
|
+
export declare function checkSequence(lastSeq: number | undefined, seq: number): SequenceVerdict;
|
|
115
|
+
/**
|
|
116
|
+
* 배치 하나를 이어 본다 — **첫 구멍에서 멈춘다.**
|
|
117
|
+
*
|
|
118
|
+
* 구멍 뒤의 봉투를 받아들이면 그 구멍이 영영 메워지지 않는다. 보내는 쪽이 `expectedSeq` 부터 다시
|
|
119
|
+
* 보내면 뒤엣것은 함께 온다.
|
|
120
|
+
*
|
|
121
|
+
* 번호가 오름차순이 아닌 배치는 그 자체가 보내는 쪽의 결함이므로 구멍과 같게 다룬다.
|
|
122
|
+
*/
|
|
123
|
+
export declare function checkSequenceRun(lastSeq: number | undefined, seqs: number[]): {
|
|
124
|
+
accepted: number;
|
|
125
|
+
lastSeq: number;
|
|
126
|
+
gap?: {
|
|
127
|
+
expectedSeq: number;
|
|
128
|
+
};
|
|
129
|
+
};
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **밀어 주는 연동의 계약** — 보내는 쪽과 받는 쪽이 같은 뜻으로 읽어야 하는 것.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 계약에 있나 (2026-08-31) ──────────────────────────────────────────
|
|
5
|
+
* 보내는 쪽은 응답 코드 하나로 「다시 보낼 것인가 · 기다릴 것인가 · 사람을 부를 것인가」를 정한다.
|
|
6
|
+
* 그 판단표가 양쪽에 따로 있으면 한쪽만 바뀌는 날이 온다. 실제로 그렇게 되어 있었다 — 트윈은 409 를
|
|
7
|
+
* 「그 트윈이 실시간으로 돌지 않는다」로 쓰고 operato-mes 는 409 를 「번호가 비었다」로 읽었다.
|
|
8
|
+
* 붙였다면 트윈이 「안 돈다」고 답하는 것을 MES 가 「번호가 비었다」로 읽어 같은 구간을 되풀이 보냈다.
|
|
9
|
+
*
|
|
10
|
+
* 계약 층의 기준이 「만드는 쪽과 읽는 쪽이 합의해야 하는가」이고, 응답 코드와 번호 규칙이 그것이다.
|
|
11
|
+
*
|
|
12
|
+
* ── 서명은 여기 없다 ─────────────────────────────────────────────────────
|
|
13
|
+
* `./webhook-signature.ts` 에 있고 `@operato/ops-contract/webhook` 으로만 나간다. `node:crypto` 를
|
|
14
|
+
* 쓰기 때문이다. 이 파일(색인에 실리는 것)에 넣으면 계약을 쓰는 브라우저 번들이 `node:crypto` 를
|
|
15
|
+
* 묶으려다 실패한다 — operato-twin 클라이언트가 이 패키지를 쓴다.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* 응답 코드 — **보내는 쪽이 무엇을 할지가 코드에서 나온다.**
|
|
19
|
+
*
|
|
20
|
+
* 코드마다 보내는 쪽의 행동이 하나로 정해져야 한다. 두 뜻을 한 코드에 담으면 보내는 쪽은 본문을 읽고
|
|
21
|
+
* 갈라야 하고, 본문 모양은 원본마다 다르다.
|
|
22
|
+
*
|
|
23
|
+
* 200 받아서 반영했다
|
|
24
|
+
* 400 본문을 레코드로 옮길 수 없다 그쪽 모양이 바뀐 것이다
|
|
25
|
+
* 401 비밀값 또는 서명이 맞지 않는다
|
|
26
|
+
* 404 모르는 연결이거나 모르는 트윈
|
|
27
|
+
* 409 번호가 비었다 `expectedSeq` 부터 다시 보낸다
|
|
28
|
+
* 422 일부가 검증에서 떨어졌다 떨어진 것만 따로 두고 나머지는 보냄으로 표시한다
|
|
29
|
+
* 500 받는 쪽 오류 다시 보내면 될 수 있다
|
|
30
|
+
* 501 그 커넥터가 밀어 주기를 받을 줄 모른다
|
|
31
|
+
* 503 그 트윈이 지금 실시간으로 돌지 않는다 들고 기다렸다가 다시 보낸다
|
|
32
|
+
*
|
|
33
|
+
* **503 인 이유.** 예전에는 409 였고 「재시도해도 같은 것」으로 분류했다. 그런데 트윈이 안 도는 것은
|
|
34
|
+
* 누가 띄우면 풀린다. 4xx 는 「보낸 요청이 잘못됐다」는 뜻이라, 그것을 받은 쪽이 포기하면 그 사실이
|
|
35
|
+
* 사라진다. 보내는 쪽이 해야 할 일은 사실을 들고 기다렸다 다시 보내는 것이므로 5xx 다.
|
|
36
|
+
*/
|
|
37
|
+
export const WEBHOOK_STATUS = {
|
|
38
|
+
ok: 200,
|
|
39
|
+
badPayload: 400,
|
|
40
|
+
badSecret: 401,
|
|
41
|
+
unknownTarget: 404,
|
|
42
|
+
sequenceGap: 409,
|
|
43
|
+
partial: 422,
|
|
44
|
+
failed: 500,
|
|
45
|
+
unsupported: 501,
|
|
46
|
+
notLive: 503
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* 응답 코드 → 보내는 쪽의 행동. **모르는 코드도 답을 낸다.**
|
|
50
|
+
*
|
|
51
|
+
* 표에 없는 코드가 오는 일이 있다(프록시의 502, 게이트웨이의 504). 그때 「모른다」로 두면 보내는 쪽이
|
|
52
|
+
* 각자 정하고, 한 곳은 버리고 다른 곳은 되풀이한다. 5xx 는 받는 쪽 사정이므로 다시 보내고, 나머지는
|
|
53
|
+
* 보낸 것이 잘못이므로 멈춘다.
|
|
54
|
+
*/
|
|
55
|
+
export function webhookSenderAction(status) {
|
|
56
|
+
switch (status) {
|
|
57
|
+
case WEBHOOK_STATUS.ok:
|
|
58
|
+
return 'accepted';
|
|
59
|
+
case WEBHOOK_STATUS.sequenceGap:
|
|
60
|
+
return 'resend-from';
|
|
61
|
+
case WEBHOOK_STATUS.partial:
|
|
62
|
+
/*
|
|
63
|
+
* **다시 보내면 안 된다.** 떨어진 것은 모양이 틀린 것이라 같은 것을 다시 보내도 또 떨어지고,
|
|
64
|
+
* 함께 간 나머지는 이미 들어갔으므로 두 번 들어간다. 떨어진 것만 따로 두고 나아간다.
|
|
65
|
+
*/
|
|
66
|
+
return 'partial';
|
|
67
|
+
case WEBHOOK_STATUS.notLive:
|
|
68
|
+
return 'wait';
|
|
69
|
+
case WEBHOOK_STATUS.failed:
|
|
70
|
+
return 'retry';
|
|
71
|
+
case WEBHOOK_STATUS.badPayload:
|
|
72
|
+
case WEBHOOK_STATUS.badSecret:
|
|
73
|
+
case WEBHOOK_STATUS.unknownTarget:
|
|
74
|
+
case WEBHOOK_STATUS.unsupported:
|
|
75
|
+
return 'stop';
|
|
76
|
+
default:
|
|
77
|
+
return status >= 500 ? 'retry' : 'stop';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** 429 를 함께 쓰는 곳이 있다 — 받는 쪽이 따라가지 못한다는 뜻이고, 행동은 503 과 같다. */
|
|
81
|
+
export const WEBHOOK_TOO_MANY = 429;
|
|
82
|
+
/**
|
|
83
|
+
* 번호를 이어 볼 수 있는가.
|
|
84
|
+
*
|
|
85
|
+
* @param lastSeq 이 단위에서 마지막으로 받아들인 번호. 받은 적이 없으면 `undefined`
|
|
86
|
+
* @param seq 이번 봉투의 번호
|
|
87
|
+
*/
|
|
88
|
+
export function checkSequence(lastSeq, seq) {
|
|
89
|
+
if (!Number.isInteger(seq) || seq < 1) {
|
|
90
|
+
throw new Error(`checkSequence: 번호는 1 이상의 정수여야 한다 — 받은 값 ${JSON.stringify(seq)}`);
|
|
91
|
+
}
|
|
92
|
+
if (lastSeq === undefined || lastSeq === null)
|
|
93
|
+
return { kind: 'first', lastSeq: seq };
|
|
94
|
+
if (seq <= lastSeq)
|
|
95
|
+
return { kind: 'behind', lastSeq };
|
|
96
|
+
if (seq === lastSeq + 1)
|
|
97
|
+
return { kind: 'next', lastSeq: seq };
|
|
98
|
+
return { kind: 'gap', lastSeq, expectedSeq: lastSeq + 1 };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 배치 하나를 이어 본다 — **첫 구멍에서 멈춘다.**
|
|
102
|
+
*
|
|
103
|
+
* 구멍 뒤의 봉투를 받아들이면 그 구멍이 영영 메워지지 않는다. 보내는 쪽이 `expectedSeq` 부터 다시
|
|
104
|
+
* 보내면 뒤엣것은 함께 온다.
|
|
105
|
+
*
|
|
106
|
+
* 번호가 오름차순이 아닌 배치는 그 자체가 보내는 쪽의 결함이므로 구멍과 같게 다룬다.
|
|
107
|
+
*/
|
|
108
|
+
export function checkSequenceRun(lastSeq, seqs) {
|
|
109
|
+
let cursor = lastSeq;
|
|
110
|
+
let accepted = 0;
|
|
111
|
+
for (const seq of seqs) {
|
|
112
|
+
const verdict = checkSequence(cursor, seq);
|
|
113
|
+
if (verdict.kind === 'gap')
|
|
114
|
+
return { accepted, lastSeq: verdict.lastSeq, gap: { expectedSeq: verdict.expectedSeq } };
|
|
115
|
+
cursor = verdict.lastSeq;
|
|
116
|
+
accepted++;
|
|
117
|
+
}
|
|
118
|
+
return { accepted, lastSeq: cursor };
|
|
119
|
+
}
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -61,6 +61,8 @@ __export(index_exports, {
|
|
|
61
61
|
UTC_OFFSET: () => UTC_OFFSET,
|
|
62
62
|
VOCABULARY_EXCEPTIONS: () => VOCABULARY_EXCEPTIONS,
|
|
63
63
|
VOCABULARY_TYPE: () => VOCABULARY_TYPE,
|
|
64
|
+
WEBHOOK_STATUS: () => WEBHOOK_STATUS,
|
|
65
|
+
WEBHOOK_TOO_MANY: () => WEBHOOK_TOO_MANY,
|
|
64
66
|
WMS_LOCATION_TYPES: () => WMS_LOCATION_TYPES,
|
|
65
67
|
WMS_TYPES: () => WMS_TYPES,
|
|
66
68
|
YARD_BIZSTEP: () => YARD_BIZSTEP,
|
|
@@ -77,6 +79,8 @@ __export(index_exports, {
|
|
|
77
79
|
bizTransactionUri: () => bizTransactionUri,
|
|
78
80
|
capabilitiesForType: () => capabilitiesForType,
|
|
79
81
|
capabilityOf: () => capabilityOf,
|
|
82
|
+
checkSequence: () => checkSequence,
|
|
83
|
+
checkSequenceRun: () => checkSequenceRun,
|
|
80
84
|
classClosure: () => classClosure,
|
|
81
85
|
classIdentifierViolation: () => classIdentifierViolation,
|
|
82
86
|
commandsOf: () => commandsOf,
|
|
@@ -171,6 +175,7 @@ __export(index_exports, {
|
|
|
171
175
|
validateDomainDefinition: () => validateDomainDefinition,
|
|
172
176
|
validateEpcisEvent: () => validateEpcisEvent,
|
|
173
177
|
validateScenario: () => validateScenario,
|
|
178
|
+
webhookSenderAction: () => webhookSenderAction,
|
|
174
179
|
weekdayAt: () => weekdayAt,
|
|
175
180
|
workingHoursBetween: () => workingHoursBetween,
|
|
176
181
|
workingTimeOfWeek: () => workingTimeOfWeek
|
|
@@ -3165,6 +3170,8 @@ var ASSET_STATUS = ["idle", "in-use"];
|
|
|
3165
3170
|
var SPECS = {
|
|
3166
3171
|
task: {
|
|
3167
3172
|
eventType: OP_EVENT.task,
|
|
3173
|
+
match: ["taskId"],
|
|
3174
|
+
matchOrder: 50,
|
|
3168
3175
|
identity: "taskId",
|
|
3169
3176
|
/* 종류가 없으면 성과를 종류별로 모을 수 없고(선언된 시간·수율이 종류로 붙는다) 지어낼 수도 없다. */
|
|
3170
3177
|
required: ["taskId", "kind", "status"],
|
|
@@ -3201,6 +3208,8 @@ var SPECS = {
|
|
|
3201
3208
|
},
|
|
3202
3209
|
equipment: {
|
|
3203
3210
|
eventType: OP_EVENT.equipment,
|
|
3211
|
+
match: ["moverId"],
|
|
3212
|
+
matchOrder: 20,
|
|
3204
3213
|
identity: "moverId",
|
|
3205
3214
|
// vocabulary-guard: allow 저널 와이어 필드(델타의 이름이 계약이다)
|
|
3206
3215
|
required: ["moverId", "kind", "status"],
|
|
@@ -3227,6 +3236,8 @@ var SPECS = {
|
|
|
3227
3236
|
},
|
|
3228
3237
|
person: {
|
|
3229
3238
|
eventType: OP_EVENT.person,
|
|
3239
|
+
match: ["personId"],
|
|
3240
|
+
matchOrder: 30,
|
|
3230
3241
|
identity: "personId",
|
|
3231
3242
|
required: ["personId", "status"],
|
|
3232
3243
|
fields: {
|
|
@@ -3244,6 +3255,8 @@ var SPECS = {
|
|
|
3244
3255
|
},
|
|
3245
3256
|
asset: {
|
|
3246
3257
|
eventType: OP_EVENT.asset,
|
|
3258
|
+
match: ["assetId"],
|
|
3259
|
+
matchOrder: 40,
|
|
3247
3260
|
identity: "assetId",
|
|
3248
3261
|
required: ["assetId", "status"],
|
|
3249
3262
|
fields: {
|
|
@@ -3261,6 +3274,8 @@ var SPECS = {
|
|
|
3261
3274
|
},
|
|
3262
3275
|
order: {
|
|
3263
3276
|
eventType: OP_EVENT.order,
|
|
3277
|
+
match: ["orderId"],
|
|
3278
|
+
matchOrder: 60,
|
|
3264
3279
|
identity: "orderId",
|
|
3265
3280
|
/*
|
|
3266
3281
|
* 요청량·이행량을 **함께** 받는다. 없으면 리듀서가 진척을 0 으로 적는데(`requested ? … : 0`),
|
|
@@ -3290,12 +3305,23 @@ var SPECS = {
|
|
|
3290
3305
|
},
|
|
3291
3306
|
quality: {
|
|
3292
3307
|
eventType: OP_EVENT.quality,
|
|
3308
|
+
match: ["moverId", "good"],
|
|
3309
|
+
matchOrder: 15,
|
|
3293
3310
|
identity: "moverId",
|
|
3294
3311
|
// vocabulary-guard: allow 저널 와이어 필드
|
|
3295
3312
|
/* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
|
|
3296
3313
|
required: ["moverId", "good", "goodCount", "scrapCount"],
|
|
3297
3314
|
// vocabulary-guard: allow
|
|
3298
|
-
|
|
3315
|
+
/*
|
|
3316
|
+
* `taskId` 는 **귀속**이다 — 이 산출이 어느 작업의 것인가. 수량의 뜻은 바뀌지 않는다(설비 누적).
|
|
3317
|
+
*
|
|
3318
|
+
* 왜 필요한가: 작업자가 올리는 실적은 작업 단위인데 이 갈래의 정체는 설비뿐이라, 두 사실이 만나는
|
|
3319
|
+
* 자리가 없었다. 귀속이 없으면 「이 작업에서 몇 개 나왔나」를 저널에서 되짚을 방법이 없다.
|
|
3320
|
+
*
|
|
3321
|
+
* 정체를 `taskId` 로 바꾸지 않는 이유는 OEE 가 설비 단위여서다. 정체를 옮기면 설비 누적을 셀 수
|
|
3322
|
+
* 없어진다.
|
|
3323
|
+
*/
|
|
3324
|
+
fields: { moverId: "string", good: "boolean", goodCount: "number", scrapCount: "number", taskId: "string", recordTime: "string" }
|
|
3299
3325
|
// vocabulary-guard: allow
|
|
3300
3326
|
},
|
|
3301
3327
|
/*
|
|
@@ -3334,6 +3360,8 @@ var SPECS = {
|
|
|
3334
3360
|
*/
|
|
3335
3361
|
"equipment-period": {
|
|
3336
3362
|
eventType: OP_EVENT.equipmentPeriod,
|
|
3363
|
+
match: ["moverId", "status", "from", "to"],
|
|
3364
|
+
matchOrder: 10,
|
|
3337
3365
|
identity: "moverId",
|
|
3338
3366
|
// vocabulary-guard: allow 저널 와이어 필드 — 전이와 같은 이름을 쓴다
|
|
3339
3367
|
required: ["moverId", "status", "from", "to"],
|
|
@@ -3368,6 +3396,8 @@ var SPECS = {
|
|
|
3368
3396
|
*/
|
|
3369
3397
|
disposition: {
|
|
3370
3398
|
eventType: OP_EVENT.disposition,
|
|
3399
|
+
match: ["subjectId", "decision"],
|
|
3400
|
+
matchOrder: 80,
|
|
3371
3401
|
identity: "subjectId",
|
|
3372
3402
|
/* 무엇을 어떻게 하기로 했나 — 둘 중 하나가 없으면 그 결정은 아무 데도 붙지 못한다. */
|
|
3373
3403
|
required: ["subjectId", "decision"],
|
|
@@ -3386,6 +3416,8 @@ var SPECS = {
|
|
|
3386
3416
|
},
|
|
3387
3417
|
test: {
|
|
3388
3418
|
eventType: OP_EVENT.test,
|
|
3419
|
+
match: ["testableObjectId"],
|
|
3420
|
+
matchOrder: 70,
|
|
3389
3421
|
identity: "testableObjectId",
|
|
3390
3422
|
/* 무엇을 어느 기준으로 시험했나 — 둘 중 하나가 없으면 그 결과는 아무 데도 붙지 못한다. */
|
|
3391
3423
|
required: ["testableObjectId", "specId"],
|
|
@@ -3419,12 +3451,16 @@ var SPECS = {
|
|
|
3419
3451
|
*/
|
|
3420
3452
|
complete: {
|
|
3421
3453
|
eventType: OP_EVENT.complete,
|
|
3454
|
+
match: ["completeAxis"],
|
|
3455
|
+
matchOrder: 100,
|
|
3422
3456
|
identity: "completeAxis",
|
|
3423
3457
|
required: ["completeAxis", "since"],
|
|
3424
3458
|
fields: { completeAxis: "string", since: "string", recordTime: "string" }
|
|
3425
3459
|
},
|
|
3426
3460
|
observation: {
|
|
3427
3461
|
eventType: OP_EVENT.observation,
|
|
3462
|
+
match: ["locationId", "propertyId"],
|
|
3463
|
+
matchOrder: 90,
|
|
3428
3464
|
identity: "locationId",
|
|
3429
3465
|
/* 자리와 속성 — 둘 중 하나가 없으면 그 관측은 아무 데도 붙지 못한다. */
|
|
3430
3466
|
required: ["locationId", "propertyId"],
|
|
@@ -3446,19 +3482,21 @@ function operationalKindOf(record) {
|
|
|
3446
3482
|
if (!record || typeof record !== "object") return void 0;
|
|
3447
3483
|
const r = record;
|
|
3448
3484
|
if (r.epc !== void 0 || r.meterId !== void 0 || r.equipmentId !== void 0) return void 0;
|
|
3449
|
-
const
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
if (has("completeAxis")) return "complete";
|
|
3485
|
+
const present = (k) => {
|
|
3486
|
+
const v = r[k];
|
|
3487
|
+
if (v === void 0 || v === null) return false;
|
|
3488
|
+
return typeof v === "string" ? v.trim().length > 0 : true;
|
|
3489
|
+
};
|
|
3490
|
+
for (const kind of MATCH_ORDER) {
|
|
3491
|
+
const spec = SPECS[kind];
|
|
3492
|
+
const fields = spec.match ?? [spec.identity];
|
|
3493
|
+
if (fields.every(present)) return kind;
|
|
3494
|
+
}
|
|
3460
3495
|
return void 0;
|
|
3461
3496
|
}
|
|
3497
|
+
var MATCH_ORDER = Object.keys(SPECS).sort(
|
|
3498
|
+
(a, b) => (SPECS[a].matchOrder ?? 1e3) - (SPECS[b].matchOrder ?? 1e3)
|
|
3499
|
+
);
|
|
3462
3500
|
function isOperationalRecord(record) {
|
|
3463
3501
|
return operationalKindOf(record) !== void 0;
|
|
3464
3502
|
}
|
|
@@ -3733,8 +3771,63 @@ function retiredVocabularyIn(line) {
|
|
|
3733
3771
|
return hits;
|
|
3734
3772
|
}
|
|
3735
3773
|
|
|
3774
|
+
// src/webhook.ts
|
|
3775
|
+
var WEBHOOK_STATUS = {
|
|
3776
|
+
ok: 200,
|
|
3777
|
+
badPayload: 400,
|
|
3778
|
+
badSecret: 401,
|
|
3779
|
+
unknownTarget: 404,
|
|
3780
|
+
sequenceGap: 409,
|
|
3781
|
+
partial: 422,
|
|
3782
|
+
failed: 500,
|
|
3783
|
+
unsupported: 501,
|
|
3784
|
+
notLive: 503
|
|
3785
|
+
};
|
|
3786
|
+
function webhookSenderAction(status) {
|
|
3787
|
+
switch (status) {
|
|
3788
|
+
case WEBHOOK_STATUS.ok:
|
|
3789
|
+
return "accepted";
|
|
3790
|
+
case WEBHOOK_STATUS.sequenceGap:
|
|
3791
|
+
return "resend-from";
|
|
3792
|
+
case WEBHOOK_STATUS.partial:
|
|
3793
|
+
return "partial";
|
|
3794
|
+
case WEBHOOK_STATUS.notLive:
|
|
3795
|
+
return "wait";
|
|
3796
|
+
case WEBHOOK_STATUS.failed:
|
|
3797
|
+
return "retry";
|
|
3798
|
+
case WEBHOOK_STATUS.badPayload:
|
|
3799
|
+
case WEBHOOK_STATUS.badSecret:
|
|
3800
|
+
case WEBHOOK_STATUS.unknownTarget:
|
|
3801
|
+
case WEBHOOK_STATUS.unsupported:
|
|
3802
|
+
return "stop";
|
|
3803
|
+
default:
|
|
3804
|
+
return status >= 500 ? "retry" : "stop";
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
var WEBHOOK_TOO_MANY = 429;
|
|
3808
|
+
function checkSequence(lastSeq, seq) {
|
|
3809
|
+
if (!Number.isInteger(seq) || seq < 1) {
|
|
3810
|
+
throw new Error(`checkSequence: \uBC88\uD638\uB294 1 \uC774\uC0C1\uC758 \uC815\uC218\uC5EC\uC57C \uD55C\uB2E4 \u2014 \uBC1B\uC740 \uAC12 ${JSON.stringify(seq)}`);
|
|
3811
|
+
}
|
|
3812
|
+
if (lastSeq === void 0 || lastSeq === null) return { kind: "first", lastSeq: seq };
|
|
3813
|
+
if (seq <= lastSeq) return { kind: "behind", lastSeq };
|
|
3814
|
+
if (seq === lastSeq + 1) return { kind: "next", lastSeq: seq };
|
|
3815
|
+
return { kind: "gap", lastSeq, expectedSeq: lastSeq + 1 };
|
|
3816
|
+
}
|
|
3817
|
+
function checkSequenceRun(lastSeq, seqs) {
|
|
3818
|
+
let cursor = lastSeq;
|
|
3819
|
+
let accepted = 0;
|
|
3820
|
+
for (const seq of seqs) {
|
|
3821
|
+
const verdict = checkSequence(cursor, seq);
|
|
3822
|
+
if (verdict.kind === "gap") return { accepted, lastSeq: verdict.lastSeq, gap: { expectedSeq: verdict.expectedSeq } };
|
|
3823
|
+
cursor = verdict.lastSeq;
|
|
3824
|
+
accepted++;
|
|
3825
|
+
}
|
|
3826
|
+
return { accepted, lastSeq: cursor };
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3736
3829
|
// src/version.ts
|
|
3737
|
-
var CONTRACT_VERSION = "0.
|
|
3830
|
+
var CONTRACT_VERSION = "0.7.0";
|
|
3738
3831
|
|
|
3739
3832
|
// src/oee.ts
|
|
3740
3833
|
function computeOee(c, nowMs) {
|
|
@@ -3808,6 +3901,8 @@ function computeOee(c, nowMs) {
|
|
|
3808
3901
|
UTC_OFFSET,
|
|
3809
3902
|
VOCABULARY_EXCEPTIONS,
|
|
3810
3903
|
VOCABULARY_TYPE,
|
|
3904
|
+
WEBHOOK_STATUS,
|
|
3905
|
+
WEBHOOK_TOO_MANY,
|
|
3811
3906
|
WMS_LOCATION_TYPES,
|
|
3812
3907
|
WMS_TYPES,
|
|
3813
3908
|
YARD_BIZSTEP,
|
|
@@ -3824,6 +3919,8 @@ function computeOee(c, nowMs) {
|
|
|
3824
3919
|
bizTransactionUri,
|
|
3825
3920
|
capabilitiesForType,
|
|
3826
3921
|
capabilityOf,
|
|
3922
|
+
checkSequence,
|
|
3923
|
+
checkSequenceRun,
|
|
3827
3924
|
classClosure,
|
|
3828
3925
|
classIdentifierViolation,
|
|
3829
3926
|
commandsOf,
|
|
@@ -3918,6 +4015,7 @@ function computeOee(c, nowMs) {
|
|
|
3918
4015
|
validateDomainDefinition,
|
|
3919
4016
|
validateEpcisEvent,
|
|
3920
4017
|
validateScenario,
|
|
4018
|
+
webhookSenderAction,
|
|
3921
4019
|
weekdayAt,
|
|
3922
4020
|
workingHoursBetween,
|
|
3923
4021
|
workingTimeOfWeek
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
8
|
+
};
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
18
|
+
|
|
19
|
+
// src/webhook-signature.ts
|
|
20
|
+
var webhook_signature_exports = {};
|
|
21
|
+
__export(webhook_signature_exports, {
|
|
22
|
+
WEBHOOK_HEADER: () => WEBHOOK_HEADER,
|
|
23
|
+
WEBHOOK_TOLERANCE_MS: () => WEBHOOK_TOLERANCE_MS,
|
|
24
|
+
signWebhook: () => signWebhook,
|
|
25
|
+
verifyWebhookSignature: () => verifyWebhookSignature,
|
|
26
|
+
webhookSigningInput: () => webhookSigningInput
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(webhook_signature_exports);
|
|
29
|
+
var import_node_crypto = require("node:crypto");
|
|
30
|
+
var WEBHOOK_HEADER = {
|
|
31
|
+
/** 보내는 쪽이 누구인가. 사람이 로그에서 읽는 용도이고, 어디로 갈지는 주소가 정한다. */
|
|
32
|
+
source: "x-ops-source",
|
|
33
|
+
/** 서명에 들어간 시각(RFC 3339). */
|
|
34
|
+
timestamp: "x-ops-timestamp",
|
|
35
|
+
/** HMAC-SHA256 hex. */
|
|
36
|
+
signature: "x-ops-signature"
|
|
37
|
+
};
|
|
38
|
+
var WEBHOOK_TOLERANCE_MS = 5 * 6e4;
|
|
39
|
+
function webhookSigningInput(timestamp, body) {
|
|
40
|
+
return `${timestamp}.${body}`;
|
|
41
|
+
}
|
|
42
|
+
function signWebhook(secret, timestamp, body) {
|
|
43
|
+
return (0, import_node_crypto.createHmac)("sha256", secret).update(webhookSigningInput(timestamp, body)).digest("hex");
|
|
44
|
+
}
|
|
45
|
+
function verifyWebhookSignature(args) {
|
|
46
|
+
const { secret, timestamp, signature, body, nowMs } = args;
|
|
47
|
+
const toleranceMs = args.toleranceMs ?? WEBHOOK_TOLERANCE_MS;
|
|
48
|
+
if (!secret) return { ok: false, reason: "no-secret" };
|
|
49
|
+
if (!signature) return { ok: false, reason: "no-signature" };
|
|
50
|
+
if (!timestamp) return { ok: false, reason: "no-timestamp" };
|
|
51
|
+
const at = Date.parse(timestamp);
|
|
52
|
+
if (Number.isNaN(at)) return { ok: false, reason: "bad-timestamp" };
|
|
53
|
+
if (Math.abs(nowMs - at) > toleranceMs) return { ok: false, reason: "expired" };
|
|
54
|
+
const expected = Buffer.from(signWebhook(secret, timestamp, body), "utf-8");
|
|
55
|
+
const given = Buffer.from(signature, "utf-8");
|
|
56
|
+
if (expected.length !== given.length) return { ok: false, reason: "mismatch" };
|
|
57
|
+
return (0, import_node_crypto.timingSafeEqual)(expected, given) ? { ok: true } : { ok: false, reason: "mismatch" };
|
|
58
|
+
}
|
|
59
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
60
|
+
0 && (module.exports = {
|
|
61
|
+
WEBHOOK_HEADER,
|
|
62
|
+
WEBHOOK_TOLERANCE_MS,
|
|
63
|
+
signWebhook,
|
|
64
|
+
verifyWebhookSignature,
|
|
65
|
+
webhookSigningInput
|
|
66
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operato/ops-contract",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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",
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"import": "./dist/index.js",
|
|
12
12
|
"require": "./dist-cjs/index.cjs"
|
|
13
|
+
},
|
|
14
|
+
"./webhook": {
|
|
15
|
+
"types": "./dist/webhook-signature.d.ts",
|
|
16
|
+
"import": "./dist/webhook-signature.js",
|
|
17
|
+
"require": "./dist-cjs/webhook-signature.cjs"
|
|
13
18
|
}
|
|
14
19
|
},
|
|
15
20
|
"files": [
|
|
@@ -19,12 +24,19 @@
|
|
|
19
24
|
"scripts": {
|
|
20
25
|
"build": "npm run build:esm && npm run build:cjs",
|
|
21
26
|
"build:esm": "tsc -p tsconfig.build.json",
|
|
22
|
-
"build:cjs": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist-cjs/index.cjs",
|
|
27
|
+
"build:cjs": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist-cjs/index.cjs && esbuild src/webhook-signature.ts --bundle --platform=node --format=cjs --outfile=dist-cjs/webhook-signature.cjs",
|
|
23
28
|
"typecheck": "tsc -p tsconfig.check.json",
|
|
24
29
|
"test": "node --test test/*.test.ts"
|
|
25
30
|
},
|
|
26
31
|
"license": "MIT",
|
|
27
32
|
"publishConfig": {
|
|
28
33
|
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"typesVersions": {
|
|
36
|
+
"*": {
|
|
37
|
+
"webhook": [
|
|
38
|
+
"./dist/webhook-signature.d.ts"
|
|
39
|
+
]
|
|
40
|
+
}
|
|
29
41
|
}
|
|
30
42
|
}
|