@onlineapps/external-io-contract 0.1.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/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # @onlineapps/external-io-contract
2
+
3
+ Sdílený kontrakt pro HTTP komunikaci OA Drive s vnějším světem.
4
+
5
+ Zdrojem pravdy jsou **JSON Schemata v `schemas/`**; kód v `src/` je jen
6
+ jejich spustitelná podoba (validátory se kompilují při načtení modulu).
7
+
8
+ ## Kdo to používá
9
+
10
+ | Konzument | K čemu |
11
+ |---|---|
12
+ | `api_gateway` | ověření podpisu příchozích webhooků, rozbor correlation reference, validace obálky |
13
+ | `api_delivery_dispatcher` | podpis odchozích volání, validace outbound obálky a výsledku |
14
+ | `pdke_orion-grit` | podpis odchozích callbacků |
15
+
16
+ **Biz služby tuhle knihovnu nepoužívají.** Dostávají a vracejí prosté
17
+ objekty; transport, podpisy ani obálky neznají. Vlastní-li biz služba
18
+ nějaký typ correlation reference (např. `ing_job`), má nad toutéž
19
+ gramatikou vlastní tenkou vrstvu — bez runtime vazby na tuto knihovnu.
20
+ Kompatibilitu obou stran drží sdílené vektory v `test-vectors/`.
21
+
22
+ ## Použití
23
+
24
+ ```js
25
+ const { correlation, hmac, validators, SIGNATURE_HEADER } = require('@onlineapps/external-io-contract');
26
+
27
+ // odchozí: podepsat přesné bajty těla
28
+ const signature = hmac.sign(rawBody, secret); // "sha256=<hex>"
29
+ res.setHeader(SIGNATURE_HEADER, signature);
30
+
31
+ // příchozí: ověřit (false = odmítnout, nikdy výjimka)
32
+ if (!hmac.verify(req.rawBody, secret, req.get(SIGNATURE_HEADER))) return res.sendStatus(401);
33
+
34
+ // zjistit, komu callback patří
35
+ const jobUuid = correlation.refOfType(body.correlation_id, 'ing_job');
36
+
37
+ // zvalidovat tvar payloadu
38
+ const { valid, errors } = validators.orionGritCallbackV1(body);
39
+ ```
40
+
41
+ ## Zásady, na kterých kód stojí
42
+
43
+ - **Podepisují se raw bajty**, nikdy znovu-serializovaný objekt. Kdo
44
+ ověřuje, musí mít původní tělo (`express.json({ verify })`), jinak se
45
+ podpis rozejde na pořadí klíčů nebo mezerách.
46
+ - **Ověření vrací `false`, nevyhazuje.** Nevalidní vstup od cizí strany
47
+ je očekávaný stav. Výjimka je vyhrazená programátorské chybě (špatný
48
+ typ těla, prázdný secret).
49
+ - **Secret sem chodí jako hodnota, ale do configu se zapisuje jako
50
+ reference.** Volající si ji vyzvedne ze SecretBoxu těsně před použitím.
51
+ - **Slovníky partnerů se sem nepromítají.** `status: "confirmed"` je
52
+ slovník orion-gritu; překlad na platformní `delivered|failed` dělá
53
+ cookbook, ne tato knihovna a ne biz služba.
54
+
55
+ ## Formát podpisu (zmrazený)
56
+
57
+ ```
58
+ X-Signature: sha256=<lowercase hex>
59
+ HMAC-SHA256(raw body bytes, shared secret)
60
+ ```
61
+
62
+ Jazykově neutrální — ověřeno proti nezávislé implementaci (`openssl
63
+ dgst -sha256 -hmac`, vektory v `test-vectors/hmac.json`) i proti PHP
64
+ `hash_hmac` na straně Meditestu. Změna schématu musí projít verzováním
65
+ prefixu, ne tichou úpravou.
66
+
67
+ ## Correlation gramatika
68
+
69
+ ```
70
+ <biz_ref_type>:<ref> např. ing_job:9a8b7c6d-1234-4567-89ab-cdef01234567
71
+ ```
72
+
73
+ Dělí se na **první** dvojtečce, takže reference smí další obsahovat.
74
+ Typ: `^[a-z][a-z0-9_]{1,31}$`. Reference:
75
+ `^[A-Za-z0-9._-][A-Za-z0-9._:-]{0,127}$`.
76
+
77
+ Skládá ji biz služba (zná byznys význam), propaguje dispatcher, vrací
78
+ echem externí systém, rozebírá gateway při zjišťování tenanta.
79
+
80
+ ## Schémata
81
+
82
+ | Soubor | Stav |
83
+ |---|---|
84
+ | `schemas/correlation-ref.schema.json` | stabilní |
85
+ | `schemas/callbacks/orion-grit.v1.schema.json` | stabilní (odpovídá `buildPayload` v pdke_orion-grit) |
86
+ | `schemas/envelopes/outbound-envelope.v0.schema.json` | **draft** — stabilizuje se s OAuth2 adapterem |
87
+ | `schemas/envelopes/inbound-webhook-envelope.v0.schema.json` | **draft** — stabilizuje se s gateway receiverem |
88
+ | `schemas/envelopes/delivery-result.v0.schema.json` | **draft** |
89
+
90
+ Obálky nesou `envelope_version`. Evoluce je additive-only; nekompatibilní
91
+ změna dostane nové `v<N>` schéma, ne tichou úpravu stávajícího.
92
+
93
+ ## Testy
94
+
95
+ ```bash
96
+ npm test
97
+ ```
98
+
99
+ ## Kontext
100
+
101
+ - `api/docs/architecture/external-io-contract.md` — cílová architektura
102
+ - `api/docs/decisions/0001-external-io-through-platform-infra.md` — proč
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@onlineapps/external-io-contract",
3
+ "version": "0.1.0",
4
+ "description": "Shared contract for external HTTP I/O — correlation grammar, HMAC webhook signing, and JSON Schemas for outbound/inbound envelopes. Consumed by api_gateway, api_delivery_dispatcher and pdke_orion-grit.",
5
+ "main": "src/index.js",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./schemas/*": "./schemas/*"
9
+ },
10
+ "scripts": {
11
+ "test": "jest",
12
+ "test:watch": "jest --watch",
13
+ "test:coverage": "jest --coverage"
14
+ },
15
+ "keywords": [
16
+ "webhook",
17
+ "hmac",
18
+ "correlation",
19
+ "envelope",
20
+ "contract",
21
+ "external-io"
22
+ ],
23
+ "author": "OnlineApps",
24
+ "license": "PROPRIETARY",
25
+ "dependencies": {
26
+ "ajv": "^8.17.1",
27
+ "ajv-formats": "^3.0.1"
28
+ },
29
+ "devDependencies": {
30
+ "jest": "^29.7.0"
31
+ },
32
+ "engines": {
33
+ "node": ">=18.0.0"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "schemas",
38
+ "test-vectors"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }
@@ -0,0 +1,56 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://onlineapps.cz/schemas/external-io/callbacks/orion-grit.v1.schema.json",
4
+ "title": "pdke_orion-grit delivery callback (v1)",
5
+ "description": "Tělo webhooku, kterým pdke_orion-grit hlásí výsledek doručení zásilky. Odpovídá funkci buildPayload v pdke_orion-grit/src/lib/webhook/webhookSender.js. Podepsáno HMAC-SHA256 v hlavičce X-Signature. Pozn.: 'status' je slovník ORION-GRITU, ne platformy — na platformní 'delivered|failed' ho překládá cookbook input_mapping, nikdy biz služba.",
6
+ "type": "object",
7
+ "required": ["delivery_id", "client_id", "status", "occurred_at"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "delivery_id": {
11
+ "type": "string",
12
+ "minLength": 1,
13
+ "maxLength": 128,
14
+ "description": "Identifikátor zásilky přidělený orion-gritem"
15
+ },
16
+ "client_id": {
17
+ "type": "string",
18
+ "minLength": 1,
19
+ "maxLength": 128,
20
+ "description": "Cílový klient v orion-gritu (např. 'phoenix')"
21
+ },
22
+ "doc_type": {
23
+ "type": ["string", "null"],
24
+ "description": "Typ dokumentu ve slovníku partnera (INVOIC, DESADV, ORDERS)"
25
+ },
26
+ "status": {
27
+ "type": "string",
28
+ "enum": ["confirmed", "failed", "failed_timeout"],
29
+ "description": "Slovník orion-gritu. Překlad na platformní delivered|failed dělá cookbook."
30
+ },
31
+ "external_ref": {
32
+ "type": ["string", "null"],
33
+ "maxLength": 255,
34
+ "description": "Reference, kterou jsme poslali v odchozím volání (např. fak_id)"
35
+ },
36
+ "correlation_id": {
37
+ "type": ["string", "null"],
38
+ "pattern": "^[a-z][a-z0-9_]{1,31}:[A-Za-z0-9._-][A-Za-z0-9._:-]{0,127}$",
39
+ "description": "Echo naší correlation reference; podle ní gateway zjistí tenanta"
40
+ },
41
+ "ack_ref": {
42
+ "type": ["string", "null"],
43
+ "maxLength": 255,
44
+ "description": "Jak bylo doručení potvrzeno (např. 'pickup:file_gone')"
45
+ },
46
+ "error": {
47
+ "type": ["string", "null"],
48
+ "description": "Text chyby při status != confirmed"
49
+ },
50
+ "occurred_at": {
51
+ "type": "string",
52
+ "format": "date-time",
53
+ "description": "Kdy událost nastala na straně orion-gritu (ISO 8601)"
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://onlineapps.cz/schemas/external-io/correlation-ref.schema.json",
4
+ "title": "Correlation reference",
5
+ "description": "Vazba mezi odchozím voláním a příchozím callbackem. Tvar '<biz_ref_type>:<ref>'; dělí se na první dvojtečce, takže ref smí dvojtečku obsahovat. Skládá ji biz služba, vrací echem externí systém, rozebírá ji gateway při resolution tenanta.",
6
+ "type": "string",
7
+ "pattern": "^[a-z][a-z0-9_]{1,31}:[A-Za-z0-9._-][A-Za-z0-9._:-]{0,127}$",
8
+ "examples": [
9
+ "ing_job:9a8b7c6d-1234-4567-89ab-cdef01234567",
10
+ "invoice:INV-2026-00042"
11
+ ]
12
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://onlineapps.cz/schemas/external-io/envelopes/delivery-result.v0.schema.json",
4
+ "title": "Delivery result (v0 draft)",
5
+ "description": "Výsledek jednoho pokusu o doručení; dispatcher ho publikuje do fronty delivery.result pro audit a monitoring. DRAFT — stabilizuje se s OAuth2 adapterem (ADR-0001 Track B).",
6
+ "type": "object",
7
+ "required": ["envelope_version", "workflow_id", "destination_name", "status"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "envelope_version": { "type": "string", "const": "0" },
11
+ "workflow_id": { "type": "string", "minLength": 1 },
12
+ "destination_name": { "type": "string" },
13
+ "destination_type": { "type": "string" },
14
+ "status": {
15
+ "type": "string",
16
+ "enum": ["success", "failed", "retrying", "dead_letter", "skipped"]
17
+ },
18
+ "http_status": { "type": ["integer", "null"] },
19
+ "attempts_made": { "type": "integer", "minimum": 0 },
20
+ "first_attempt_at": { "type": ["string", "null"], "format": "date-time" },
21
+ "last_attempt_at": { "type": ["string", "null"], "format": "date-time" },
22
+ "next_retry_at": { "type": ["string", "null"], "format": "date-time" },
23
+ "external_reference": {
24
+ "type": ["string", "null"],
25
+ "description": "Identifikátor, který partner vrátil v odpovědi (např. delivery_id)"
26
+ },
27
+ "error": { "type": ["string", "null"] },
28
+ "expects_callback": {
29
+ "type": ["object", "null"],
30
+ "description": "Vyplněno, pokud tato destination očekává asynchronní callback",
31
+ "additionalProperties": false,
32
+ "properties": {
33
+ "provider_slug": { "type": "string" },
34
+ "correlation_id_sent": { "type": ["string", "null"] }
35
+ }
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,66 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://onlineapps.cz/schemas/external-io/envelopes/inbound-webhook-envelope.v0.schema.json",
4
+ "title": "Inbound webhook envelope (v0 draft)",
5
+ "description": "Co gateway sestaví z ověřeného příchozího webhooku předtím, než z něj složí vstup workflow. DRAFT — stabilizuje se implementací gateway webhook receiveru (ADR-0001 Track C). Pozn.: samostatná fronta 'webhook.received' se nestaví; gateway publikuje standardní workflow start, tato obálka je vnitřní mezistupeň a podklad pro audit.",
6
+ "type": "object",
7
+ "required": ["envelope_version", "provider_slug", "received_at", "correlation"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "envelope_version": { "type": "string", "const": "0" },
11
+ "webhook_received_id": {
12
+ "type": "string",
13
+ "description": "UUID přidělené gateway pro audit trail"
14
+ },
15
+ "provider_slug": {
16
+ "type": "string",
17
+ "pattern": "^[a-z][a-z0-9-]{1,63}$",
18
+ "description": "Který provider config zprávu ověřil"
19
+ },
20
+ "provider_message_id": {
21
+ "type": ["string", "null"],
22
+ "description": "Identifikátor zprávy u partnera; klíč pro deduplikaci"
23
+ },
24
+ "received_at": { "type": "string", "format": "date-time" },
25
+ "correlation": {
26
+ "type": "object",
27
+ "required": ["correlation_id", "tenant_id"],
28
+ "additionalProperties": false,
29
+ "properties": {
30
+ "correlation_id": {
31
+ "type": "string",
32
+ "pattern": "^[a-z][a-z0-9_]{1,31}:[A-Za-z0-9._-][A-Za-z0-9._:-]{0,127}$"
33
+ },
34
+ "tenant_id": {
35
+ "type": "integer",
36
+ "description": "Bez resolvovaného tenanta se nesmí publikovat nic — jinak by šlo o neautorizovaný tok dat"
37
+ },
38
+ "workspace_id": { "type": ["integer", "null"] },
39
+ "resolved_reference": {
40
+ "type": ["object", "null"],
41
+ "description": "Co lookup našel (tabulka + uuid), pro audit",
42
+ "properties": {
43
+ "table": { "type": "string" },
44
+ "uuid": { "type": "string" }
45
+ }
46
+ }
47
+ }
48
+ },
49
+ "cookbook_trigger": {
50
+ "type": "object",
51
+ "required": ["cookbook_slug", "input"],
52
+ "additionalProperties": false,
53
+ "properties": {
54
+ "cookbook_slug": {
55
+ "type": "string",
56
+ "pattern": "^[a-z][a-z0-9-]{1,63}$",
57
+ "description": "Název souboru cookbooku (bez přípony) v api/config/cookbooks/"
58
+ },
59
+ "input": {
60
+ "type": "object",
61
+ "description": "Vstup workflow sestavený podle input_mapping v provider configu; už v platformním slovníku"
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,60 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://onlineapps.cz/schemas/external-io/envelopes/outbound-envelope.v0.schema.json",
4
+ "title": "Outbound delivery envelope (v0 draft)",
5
+ "description": "Normalizovaný tvar, který dispatcher předá destination adapteru. DRAFT — stabilizuje se implementací OAuth2 adapteru (ADR-0001 Track B). Auth se sem NIKDY nedostane v otevřené podobě; adapter si hodnotu vyzvedne ze SecretBoxu podle reference.",
6
+ "type": "object",
7
+ "required": ["envelope_version", "workflow_id", "destination", "body"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "envelope_version": { "type": "string", "const": "0" },
11
+ "workflow_id": { "type": "string", "minLength": 1 },
12
+ "correlation_id": {
13
+ "type": ["string", "null"],
14
+ "pattern": "^[a-z][a-z0-9_]{1,31}:[A-Za-z0-9._-][A-Za-z0-9._:-]{0,127}$"
15
+ },
16
+ "destination": {
17
+ "type": "object",
18
+ "required": ["type", "url"],
19
+ "additionalProperties": false,
20
+ "properties": {
21
+ "type": {
22
+ "type": "string",
23
+ "description": "Klíč adapteru v DestinationFactory (webhook, webhook.oauth2_client_credentials, …)"
24
+ },
25
+ "name": { "type": "string" },
26
+ "url": { "type": "string", "format": "uri" },
27
+ "method": { "type": "string", "enum": ["POST", "PUT"], "default": "POST" },
28
+ "timeout_ms": { "type": "integer", "minimum": 1 },
29
+ "idempotency_key": {
30
+ "type": ["string", "null"],
31
+ "description": "Zpravidla workflow_id — retry nesmí u partnera zdvojit efekt"
32
+ },
33
+ "headers": {
34
+ "type": "object",
35
+ "description": "Už vyhodnocené hlavičky (bez auth — tu doplní adapter)",
36
+ "additionalProperties": { "type": "string" }
37
+ },
38
+ "auth_ref": {
39
+ "type": ["object", "null"],
40
+ "description": "Reference na tajemství, nikdy hodnota",
41
+ "additionalProperties": true
42
+ }
43
+ }
44
+ },
45
+ "body": {
46
+ "type": "object",
47
+ "description": "Tělo vyhodnocené z body_template v cookbooku"
48
+ },
49
+ "audit_context": {
50
+ "type": "object",
51
+ "additionalProperties": false,
52
+ "properties": {
53
+ "tenant_id": { "type": ["integer", "null"] },
54
+ "workspace_id": { "type": ["integer", "null"] },
55
+ "cookbook_name": { "type": ["string", "null"] },
56
+ "dispatched_at": { "type": "string", "format": "date-time" }
57
+ }
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Correlation reference — gramatika `<biz_ref_type>:<ref>`.
5
+ *
6
+ * Correlation id je jediná vazba mezi odchozím voláním a příchozím
7
+ * callbackem. Skládá ho biz služba (zná byznys význam), propaguje
8
+ * dispatcher, vrací echem externí systém a rozebírá gateway, aby zjistil
9
+ * tenanta.
10
+ *
11
+ * Tento modul je ZÁMĚRNĚ generický — nezná žádný konkrétní typ reference
12
+ * (`ing_job`, `invoice`, …). Znalost typu patří té biz službě, která ho
13
+ * vlastní; ta má vlastní tenkou vrstvu nad touto gramatikou. Kompatibilitu
14
+ * obou stran drží sdílené vektory v `test-vectors/correlation.json`.
15
+ *
16
+ * @module src/correlation
17
+ */
18
+
19
+ /** Typ byznys entity: lowercase snake_case, 2..32 znaků. */
20
+ const REF_TYPE_RE = /^[a-z][a-z0-9_]{1,31}$/;
21
+
22
+ /** Referenční hodnota: bezpečné znaky, 1..128; dvojtečka smí být uvnitř. */
23
+ const REF_VALUE_RE = /^[A-Za-z0-9._-][A-Za-z0-9._:-]{0,127}$/;
24
+
25
+ /** Celý correlation id — pro použití v JSON Schema `pattern`. */
26
+ const CORRELATION_RE = /^[a-z][a-z0-9_]{1,31}:[A-Za-z0-9._-][A-Za-z0-9._:-]{0,127}$/;
27
+
28
+ class CorrelationError extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = 'CorrelationError';
32
+ this.code = 'INVALID_CORRELATION_REF';
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Sestaví correlation id.
38
+ *
39
+ * @param {string} refType typ byznys entity, např. 'ing_job'
40
+ * @param {string} ref identifikátor v rámci typu
41
+ * @returns {string} `<refType>:<ref>`
42
+ * @throws {CorrelationError}
43
+ */
44
+ function build(refType, ref) {
45
+ if (typeof refType !== 'string' || !REF_TYPE_RE.test(refType)) {
46
+ throw new CorrelationError(
47
+ `[external-io-contract/correlation] refType must match ${REF_TYPE_RE} - got ${JSON.stringify(refType)}`
48
+ );
49
+ }
50
+ if (typeof ref !== 'string' || !REF_VALUE_RE.test(ref)) {
51
+ throw new CorrelationError(
52
+ `[external-io-contract/correlation] ref must match ${REF_VALUE_RE} - got ${JSON.stringify(ref)}`
53
+ );
54
+ }
55
+ return `${refType}:${ref}`;
56
+ }
57
+
58
+ /**
59
+ * Rozebere correlation id na složky. Dělí se na PRVNÍ dvojtečce, takže
60
+ * `ing_job:a:b` → typ `ing_job`, ref `a:b`.
61
+ *
62
+ * @param {string} correlationId
63
+ * @returns {{ refType: string, ref: string }}
64
+ * @throws {CorrelationError}
65
+ */
66
+ function parse(correlationId) {
67
+ if (typeof correlationId !== 'string' || correlationId.length === 0) {
68
+ throw new CorrelationError(
69
+ '[external-io-contract/correlation] correlation_id must be a non-empty string'
70
+ );
71
+ }
72
+ const sep = correlationId.indexOf(':');
73
+ if (sep <= 0 || sep === correlationId.length - 1) {
74
+ throw new CorrelationError(
75
+ `[external-io-contract/correlation] correlation_id must be "<biz_ref_type>:<ref>" - got ${JSON.stringify(correlationId)}`
76
+ );
77
+ }
78
+ const refType = correlationId.slice(0, sep);
79
+ const ref = correlationId.slice(sep + 1);
80
+ if (!REF_TYPE_RE.test(refType)) {
81
+ throw new CorrelationError(
82
+ `[external-io-contract/correlation] refType part must match ${REF_TYPE_RE} - got ${JSON.stringify(refType)}`
83
+ );
84
+ }
85
+ if (!REF_VALUE_RE.test(ref)) {
86
+ throw new CorrelationError(
87
+ `[external-io-contract/correlation] ref part must match ${REF_VALUE_RE} - got ${JSON.stringify(ref)}`
88
+ );
89
+ }
90
+ return { refType, ref };
91
+ }
92
+
93
+ /**
94
+ * Nevýjimková varianta `parse` — pro validační cesty, kde je nevalidní
95
+ * vstup očekávaný stav (příchozí webhook od cizí strany), ne chyba kódu.
96
+ *
97
+ * @param {string} correlationId
98
+ * @returns {boolean}
99
+ */
100
+ function isValid(correlationId) {
101
+ return typeof correlationId === 'string' && CORRELATION_RE.test(correlationId);
102
+ }
103
+
104
+ /**
105
+ * Vytáhne referenci, jen pokud je očekávaného typu. Callback pro cizí
106
+ * bounded context (`invoice:…` tam, kde se čeká `ing_job:…`) tak nikdy
107
+ * neprojde omylem.
108
+ *
109
+ * @param {string} correlationId
110
+ * @param {string} expectedRefType
111
+ * @returns {string} samotná reference
112
+ * @throws {CorrelationError}
113
+ */
114
+ function refOfType(correlationId, expectedRefType) {
115
+ const { refType, ref } = parse(correlationId);
116
+ if (refType !== expectedRefType) {
117
+ throw new CorrelationError(
118
+ `[external-io-contract/correlation] expected refType "${expectedRefType}" - got "${refType}" ` +
119
+ '(correlation_id belongs to another bounded context)'
120
+ );
121
+ }
122
+ return ref;
123
+ }
124
+
125
+ module.exports = {
126
+ build,
127
+ parse,
128
+ isValid,
129
+ refOfType,
130
+ CorrelationError,
131
+ patterns: Object.freeze({
132
+ REF_TYPE_RE,
133
+ REF_VALUE_RE,
134
+ CORRELATION_RE
135
+ })
136
+ };
package/src/hmac.js ADDED
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webhook payload signing — HMAC-SHA256 nad raw tělem requestu.
5
+ *
6
+ * Schéma (zmrazené, jazykově neutrální):
7
+ * hlavička `X-Signature: sha256=<lowercase hex>`
8
+ * hodnota = HMAC-SHA256(raw body bytes, shared secret)
9
+ *
10
+ * Podepisuje se VŽDY přesně ten bajtový obsah, který jde po drátě —
11
+ * nikdy znovu-serializovaný objekt. Kdo ověřuje, musí mít raw tělo
12
+ * (v Expressu: `express.json({ verify })` nebo `express.raw`), jinak
13
+ * se podpis rozejde na pořadí klíčů či mezerách.
14
+ *
15
+ * @module src/hmac
16
+ */
17
+
18
+ const { createHmac, timingSafeEqual } = require('node:crypto');
19
+
20
+ /** Název hlavičky, ve které podpis cestuje. */
21
+ const SIGNATURE_HEADER = 'X-Signature';
22
+
23
+ /** Prefix hodnoty hlavičky — drží prostor pro budoucí algoritmy. */
24
+ const SIGNATURE_PREFIX = 'sha256=';
25
+
26
+ function assertBody(rawBody) {
27
+ if (typeof rawBody !== 'string' && !Buffer.isBuffer(rawBody)) {
28
+ throw new TypeError(
29
+ '[external-io-contract/hmac] rawBody must be a string or Buffer - ' +
30
+ 'sign and verify the exact bytes sent over the wire, never a re-serialized object'
31
+ );
32
+ }
33
+ }
34
+
35
+ function assertSecret(secret) {
36
+ if (typeof secret !== 'string' || secret.length === 0) {
37
+ throw new TypeError('[external-io-contract/hmac] secret must be a non-empty string');
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Spočítá hodnotu podpisové hlavičky.
43
+ *
44
+ * @param {string|Buffer} rawBody přesné bajty těla requestu
45
+ * @param {string} secret sdílené tajemství (z SecretBoxu, nikdy z configu)
46
+ * @returns {string} `sha256=<hex>` — hodnota pro hlavičku X-Signature
47
+ */
48
+ function sign(rawBody, secret) {
49
+ assertBody(rawBody);
50
+ assertSecret(secret);
51
+ const digest = createHmac('sha256', secret)
52
+ .update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8'))
53
+ .digest('hex');
54
+ return `${SIGNATURE_PREFIX}${digest}`;
55
+ }
56
+
57
+ /**
58
+ * Ověří podpis proti raw tělu. Konstantní v čase vůči obsahu podpisu.
59
+ *
60
+ * Vrací `false` místo výjimky pro každý případ „nesedí to" (chybí
61
+ * hlavička, jiný prefix, jiná délka, jiná hodnota) — volající tak má
62
+ * jednu větev pro odmítnutí. Výjimka je jen pro programátorskou chybu
63
+ * (nesmyslný typ těla nebo prázdný secret).
64
+ *
65
+ * @param {string|Buffer} rawBody přesné bajty přijatého těla
66
+ * @param {string} secret sdílené tajemství
67
+ * @param {string} headerValue obsah hlavičky X-Signature
68
+ * @returns {boolean}
69
+ */
70
+ function verify(rawBody, secret, headerValue) {
71
+ assertBody(rawBody);
72
+ assertSecret(secret);
73
+ if (typeof headerValue !== 'string' || !headerValue.startsWith(SIGNATURE_PREFIX)) {
74
+ return false;
75
+ }
76
+ const expected = Buffer.from(sign(rawBody, secret), 'utf8');
77
+ const received = Buffer.from(headerValue, 'utf8');
78
+ // timingSafeEqual vyžaduje shodnou délku; rozdílná délka = nesedí
79
+ if (expected.length !== received.length) return false;
80
+ return timingSafeEqual(expected, received);
81
+ }
82
+
83
+ module.exports = { sign, verify, SIGNATURE_HEADER, SIGNATURE_PREFIX };
package/src/index.js ADDED
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @onlineapps/external-io-contract
5
+ *
6
+ * Sdílený kontrakt pro HTTP komunikaci s vnějším světem. Používají ho:
7
+ * - api_gateway — ověření podpisu příchozích webhooků,
8
+ * rozbor correlation reference
9
+ * - api_delivery_dispatcher — podpis odchozích volání, validace obálek
10
+ * - pdke_orion-grit — podpis odchozích callbacků
11
+ *
12
+ * Zdroj pravdy jsou JSON Schemata v `schemas/`; kód je jen jejich
13
+ * spustitelná podoba. Biz služby tuhle knihovnu NEPOUŽÍVAJÍ — dostávají
14
+ * a vracejí prosté objekty, transport neznají.
15
+ *
16
+ * @module @onlineapps/external-io-contract
17
+ */
18
+
19
+ const correlation = require('./correlation');
20
+ const hmac = require('./hmac');
21
+ const { validators, schemas } = require('./validators');
22
+
23
+ module.exports = {
24
+ correlation,
25
+ hmac,
26
+ validators,
27
+ schemas,
28
+ SIGNATURE_HEADER: hmac.SIGNATURE_HEADER
29
+ };
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Validátory kompilované ze schémat při načtení modulu.
5
+ *
6
+ * Schémata v `schemas/` jsou zdroj pravdy; tenhle modul je jen jejich
7
+ * spustitelná podoba. Kompilace probíhá jednou za život procesu.
8
+ *
9
+ * @module src/validators
10
+ */
11
+
12
+ const Ajv = require('ajv');
13
+ const addFormats = require('ajv-formats');
14
+
15
+ const correlationRefSchema = require('../schemas/correlation-ref.schema.json');
16
+ const orionGritCallbackV1 = require('../schemas/callbacks/orion-grit.v1.schema.json');
17
+ const outboundEnvelopeV0 = require('../schemas/envelopes/outbound-envelope.v0.schema.json');
18
+ const inboundWebhookEnvelopeV0 = require('../schemas/envelopes/inbound-webhook-envelope.v0.schema.json');
19
+ const deliveryResultV0 = require('../schemas/envelopes/delivery-result.v0.schema.json');
20
+
21
+ const schemas = Object.freeze({
22
+ correlationRef: correlationRefSchema,
23
+ orionGritCallbackV1,
24
+ outboundEnvelopeV0,
25
+ inboundWebhookEnvelopeV0,
26
+ deliveryResultV0
27
+ });
28
+
29
+ const ajv = new Ajv({ allErrors: true, strict: false });
30
+ addFormats(ajv);
31
+
32
+ /**
33
+ * Jedna chyba validace v čitelné podobě.
34
+ * @typedef {{ path: string, message: string }} ValidationIssue
35
+ */
36
+
37
+ /**
38
+ * Výsledek validace. `valid: false` NENÍ výjimka — nevalidní vstup od
39
+ * externí strany je očekávaný stav, ne chyba kódu.
40
+ * @typedef {{ valid: boolean, errors: ValidationIssue[] }} ValidationResult
41
+ */
42
+
43
+ function toIssues(ajvErrors) {
44
+ if (!ajvErrors) return [];
45
+ return ajvErrors.map((e) => ({
46
+ path: e.instancePath === '' ? '/' : e.instancePath,
47
+ message: e.message || 'invalid'
48
+ }));
49
+ }
50
+
51
+ function compile(schema, label) {
52
+ const validateFn = ajv.compile(schema);
53
+ /**
54
+ * @param {*} data
55
+ * @returns {ValidationResult}
56
+ */
57
+ return function validate(data) {
58
+ const ok = validateFn(data);
59
+ return ok
60
+ ? { valid: true, errors: [] }
61
+ : { valid: false, errors: toIssues(validateFn.errors), schema: label };
62
+ };
63
+ }
64
+
65
+ const validators = Object.freeze({
66
+ correlationRef: compile(correlationRefSchema, 'correlation-ref'),
67
+ orionGritCallbackV1: compile(orionGritCallbackV1, 'callbacks/orion-grit.v1'),
68
+ outboundEnvelopeV0: compile(outboundEnvelopeV0, 'envelopes/outbound-envelope.v0'),
69
+ inboundWebhookEnvelopeV0: compile(inboundWebhookEnvelopeV0, 'envelopes/inbound-webhook-envelope.v0'),
70
+ deliveryResultV0: compile(deliveryResultV0, 'envelopes/delivery-result.v0')
71
+ });
72
+
73
+ module.exports = { validators, schemas };
@@ -0,0 +1,49 @@
1
+ {
2
+ "$comment": "Sdílené vektory gramatiky correlation reference. Tuto knihovnu používá gateway; biz služby mají vlastní tenkou implementaci nad toutéž gramatikou (např. api_biz/ingest/src/lib/correlation.js). Runtime závislost mezi nimi NENÍ — kompatibilitu drží právě tyto vektory. Když se změní gramatika, musí se změnit tady i tam, jinak testy padnou.",
3
+ "valid": [
4
+ {
5
+ "input": "ing_job:9a8b7c6d-1234-4567-89ab-cdef01234567",
6
+ "refType": "ing_job",
7
+ "ref": "9a8b7c6d-1234-4567-89ab-cdef01234567",
8
+ "note": "typický případ — biz-ingest job"
9
+ },
10
+ {
11
+ "input": "invoice:INV-2026-00042",
12
+ "refType": "invoice",
13
+ "ref": "INV-2026-00042",
14
+ "note": "jiný bounded context, ref není uuid"
15
+ },
16
+ {
17
+ "input": "ing_job:a:b:c",
18
+ "refType": "ing_job",
19
+ "ref": "a:b:c",
20
+ "note": "dělí se na PRVNÍ dvojtečce, ref smí další obsahovat"
21
+ },
22
+ {
23
+ "input": "raw_doc:abc",
24
+ "refType": "raw_doc",
25
+ "ref": "abc",
26
+ "note": "snake_case v typu"
27
+ },
28
+ {
29
+ "input": "ing_job:ABC.def_ghi-123",
30
+ "refType": "ing_job",
31
+ "ref": "ABC.def_ghi-123",
32
+ "note": "v referenci jsou povolené tečka, podtržítko, pomlčka a velká písmena"
33
+ }
34
+ ],
35
+ "invalid": [
36
+ { "input": "IngJob:abc", "reason": "velká písmena v typu" },
37
+ { "input": "1job:abc", "reason": "typ začíná číslicí" },
38
+ { "input": "_job:abc", "reason": "typ začíná podtržítkem" },
39
+ { "input": "ing-job:abc", "reason": "pomlčka v typu" },
40
+ { "input": "i:abc", "reason": "typ kratší než 2 znaky" },
41
+ { "input": "ing_job", "reason": "chybí dvojtečka" },
42
+ { "input": ":abc", "reason": "prázdný typ" },
43
+ { "input": "ing_job:", "reason": "prázdná reference" },
44
+ { "input": "ing_job::abc", "reason": "reference začíná dvojtečkou" },
45
+ { "input": "ing_job:a b", "reason": "mezera v referenci" },
46
+ { "input": "ing_job:../etc/passwd", "reason": "lomítko v referenci (path traversal)" },
47
+ { "input": "", "reason": "prázdný řetězec" }
48
+ ]
49
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$comment": "Known-good HMAC-SHA256 vectors generated by an INDEPENDENT implementation (openssl dgst -sha256 -hmac) on 2026-08-19. They freeze the wire format: any change to the signing scheme breaks these tests on purpose. Regenerate only when the scheme is intentionally versioned. Command: printf '%s' \"<body>\" | openssl dgst -sha256 -hmac \"<secret>\"",
3
+ "algorithm": "HMAC-SHA256",
4
+ "header": "X-Signature",
5
+ "value_format": "sha256=<lowercase hex>",
6
+ "vectors": [
7
+ {
8
+ "name": "typical orion-grit callback body",
9
+ "secret": "test-secret-1234567890abcdef",
10
+ "body": "{\"delivery_id\":\"9a8b7c6d\",\"status\":\"confirmed\"}",
11
+ "expected_hex": "e249e8f33480153865e9969d148442d2911520c28da114ebf462f862606aa6bb"
12
+ },
13
+ {
14
+ "name": "empty JSON object",
15
+ "secret": "another-secret-key-for-vectors",
16
+ "body": "{}",
17
+ "expected_hex": "b98e92437622a66fce33026d4407af51ce15b34ca7b2e4847336650fe284b58e"
18
+ },
19
+ {
20
+ "name": "utf8 in both secret and body (diacritics must be signed as utf8 bytes)",
21
+ "secret": "utf8-secret-ěščřž",
22
+ "body": "{\"note\":\"diakritika ěščřžýáíé\"}",
23
+ "expected_hex": "ef802847967892dc388df4eddbcea26f37478bc82e11852d005dfc2c9e620c05"
24
+ }
25
+ ]
26
+ }