@dzhechkov/p-replicator 1.12.0 → 1.13.1
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/.dz-manifest.json +225 -61
- package/CHANGELOG.md +148 -1
- package/LICENSE +21 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +2 -2
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/05_architecture.md +1 -1
- package/README/eng/README.md +2 -1
- package/README/ru/01_quickstart.md +2 -2
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +2 -1
- package/README/ru/html/index.html +8 -8
- package/README.md +132 -9
- package/bin/cli.js +0 -0
- package/package.json +10 -11
- package/sbom.json +470 -60
- package/scripts/check-pipeline-gaps.sh +0 -0
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +5 -1
- package/src/utils.js +32 -3
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +29 -5
- package/templates/.claude/commands/go.md +6 -8
- package/templates/.claude/commands/harvest.md +5 -7
- package/templates/.claude/commands/replicate.md +169 -44
- package/templates/.claude/commands/start.md +28 -7
- package/templates/.claude/hooks/capture-source-path.cjs +795 -0
- package/templates/.claude/hooks/check-canon.cjs +493 -0
- package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
- package/templates/.claude/hooks/check-external-deps.cjs +288 -0
- package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
- package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
- package/templates/.claude/hooks/check-job-contract.cjs +501 -0
- package/templates/.claude/hooks/check-look-origin.cjs +240 -0
- package/templates/.claude/hooks/check-look-trace.cjs +385 -0
- package/templates/.claude/hooks/check-metric-source.cjs +296 -0
- package/templates/.claude/hooks/check-model-cost.cjs +470 -0
- package/templates/.claude/hooks/check-ports.cjs +27 -6
- package/templates/.claude/hooks/check-source-version.cjs +312 -0
- package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
- package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/rules/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +5 -6
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- package/templates/.claude/rules/long-running-job.md +73 -0
- package/templates/.claude/rules/model-call-cost.md +85 -0
- package/templates/.claude/rules/replicate-pipeline.md +121 -52
- package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
- package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
- package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
- package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
- package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
- package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
- package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
- package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
- package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +173 -725
- package/tests/snapshot/baseline.json +60 -38
- package/tests/unit/capture-source-path.test.js +492 -0
- package/tests/unit/check-canon.test.js +403 -0
- package/tests/unit/check-embed-contract.test.js +422 -0
- package/tests/unit/check-external-deps.test.js +363 -0
- package/tests/unit/check-file-ownership.test.js +388 -0
- package/tests/unit/check-handoff-manifest.test.js +410 -0
- package/tests/unit/check-job-contract.test.js +514 -0
- package/tests/unit/check-look-origin.test.js +180 -0
- package/tests/unit/check-look-trace.test.js +420 -0
- package/tests/unit/check-metric-source.test.js +325 -0
- package/tests/unit/check-model-cost.test.js +425 -0
- package/tests/unit/check-ports.test.js +46 -2
- package/tests/unit/check-source-version.test.js +344 -0
- package/tests/unit/check-swarm-receipts.test.js +231 -0
- package/tests/unit/check-webhook-contract.test.js +536 -0
- package/tests/unit/db-port-rule.test.js +8 -2
- package/tests/unit/detection-ladder-registry.test.js +2 -2
- package/tests/unit/generator-swarm-contract.test.js +287 -0
- package/tests/unit/guard-honest-input-meta.test.js +64 -0
- package/tests/unit/honest-failure-rules.test.js +91 -9
- package/tests/unit/look-phase-contract.test.js +231 -0
- package/tests/unit/negative-conclusion-gate.test.js +300 -0
- package/tests/unit/quote-provenance.test.js +122 -0
- package/tests/unit/utils.test.js +40 -3
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* check-webhook-contract.cjs — событие пришло дважды и пришло ОТ КОГО УГОДНО. Что решено?
|
|
6
|
+
*
|
|
7
|
+
* NOT an event hook. Like `check-ports.cjs`, `check-look-trace.cjs`, `check-growth-trace.cjs`,
|
|
8
|
+
* `check-docs-complete.cjs`, `check-swarm-receipts.cjs` and `check-embed-contract.cjs`, it lives here
|
|
9
|
+
* because this directory already carries plain Node utilities; nothing registers it in settings.json.
|
|
10
|
+
* That is deliberate: this package's hooks are NON-BLOCKING by contract (pinned by
|
|
11
|
+
* tests/unit/hooks-project-anchored.test.js, which requires exit 0), so a hook could never refuse
|
|
12
|
+
* anything — it could only print. Invoke it:
|
|
13
|
+
*
|
|
14
|
+
* node .claude/hooks/check-webhook-contract.cjs [path-to-project]
|
|
15
|
+
*
|
|
16
|
+
* WHY IT EXISTS — the failure, before the technology.
|
|
17
|
+
*
|
|
18
|
+
* A webhook is an INCOMING call from someone else's system, most often a payment provider. Two facts
|
|
19
|
+
* about that caller decide everything below, and neither is a matter of taste:
|
|
20
|
+
*
|
|
21
|
+
* 1. IT DELIVERS THE SAME EVENT MORE THAN ONCE, BY CONSTRUCTION. Providers promise at-least-once,
|
|
22
|
+
* never exactly-once: a timeout, a 500, a network blip on the ACK — and the event is sent again.
|
|
23
|
+
* A handler with no repeat key credits the partner's commission twice, and NOBODY NOTICES,
|
|
24
|
+
* because each of the two credits is a perfectly legitimate row on its own. There is no error
|
|
25
|
+
* log, no alert, no failing request: the failure has no symptom, only wrong money.
|
|
26
|
+
*
|
|
27
|
+
* 2. THE ADDRESS IS PUBLIC, SO ANYONE CAN POST TO IT. Without signature verification the endpoint
|
|
28
|
+
* accepts a "payment succeeded" event from any stranger who guessed the URL.
|
|
29
|
+
*
|
|
30
|
+
* The SAME retry topology produces a third failure that is usually left out, and leaving it out is
|
|
31
|
+
* how a team ships a "fixed" handler that still loses money:
|
|
32
|
+
*
|
|
33
|
+
* 3. THE ORDER IS NOT GUARANTEED. Independent retries mean event B can be applied after event A
|
|
34
|
+
* that happened later. A handler that assigns state blindly (`subscription.status = ...`) has a
|
|
35
|
+
* retried old event overwrite the newer one — again silently, again for money. This checker
|
|
36
|
+
* therefore answers for ordering too; the rule `.claude/rules/incoming-webhooks.md` records why
|
|
37
|
+
* that is one obligation and not two documents.
|
|
38
|
+
*
|
|
39
|
+
* WHAT THIS FILE CAN AND CANNOT DECIDE — read before trusting exit 0.
|
|
40
|
+
*
|
|
41
|
+
* It reads a DECLARATION, `docs/webhook-contract.md`, and decides only what a declaration can settle:
|
|
42
|
+
* that a repeat key is NAMED, that it comes from the sender's event rather than being invented on
|
|
43
|
+
* receipt, that it is stored somewhere that survives a restart and is shared between workers, that
|
|
44
|
+
* the exclusion is atomic rather than a check-then-insert race, that the signature is verified over
|
|
45
|
+
* the RAW body BEFORE parsing with a constant-time comparison inside a bounded freshness window, that
|
|
46
|
+
* ordering has an answer that is not the false belief "the sender guarantees it", and that each of
|
|
47
|
+
* the three failure classes points at a test file THAT EXISTS.
|
|
48
|
+
*
|
|
49
|
+
* It does NOT run the project's tests, does NOT connect to its database, and does NOT read its source
|
|
50
|
+
* in any of the languages a replicated product might be written in — this package has ZERO
|
|
51
|
+
* dependencies. So it cannot know whether the named test really delivers ONE event TWICE and asserts
|
|
52
|
+
* ONE credit, nor whether the unique index really exists in the deployed schema. That half is layer
|
|
53
|
+
* 3/4 and the rule says so in the same words.
|
|
54
|
+
*
|
|
55
|
+
* THE EXACT FORM OF `docs/webhook-contract.md` — the rule delegates it here on purpose: this file is
|
|
56
|
+
* not part of the always-loaded corpus, so the long form costs nothing per run, while the rule keeps
|
|
57
|
+
* only the decision the reader must carry.
|
|
58
|
+
*
|
|
59
|
+
* **Входящие вебхуки:** да (да | нет — `нет` is a legitimate answer)
|
|
60
|
+
* **Отправитель:** Stripe
|
|
61
|
+
* **Проверка повторной доставкой:** ВЫПОЛНЕНА (ВЫПОЛНЕНА | НЕ ВЫПОЛНЕНА)
|
|
62
|
+
* **Причина:** — (required when НЕ ВЫПОЛНЕНА; one of REASONS below)
|
|
63
|
+
* **Ключ повторности:** event.id
|
|
64
|
+
* **Источник ключа:** событие-отправителя (событие-отправителя | сгенерирован-получателем)
|
|
65
|
+
* **Хранилище ключа:** таблица webhook_events, колонка event_id
|
|
66
|
+
* **Механизм исключения:** уникальный-индекс (уникальный-индекс | атомарная-вставка |
|
|
67
|
+
* проверка-перед-вставкой)
|
|
68
|
+
* **Что подписано:** сырое-тело (сырое-тело | разобранное-тело)
|
|
69
|
+
* **Когда проверяется подпись:** до-разбора (до-разбора | после-разбора)
|
|
70
|
+
* **Сравнение подписи:** постоянное-время (постоянное-время | обычное)
|
|
71
|
+
* **Окно свежести (секунды):** 300
|
|
72
|
+
* **Порядок событий:** версия-из-события (версия-из-события | перестановочен |
|
|
73
|
+
* гарантирован-отправителем)
|
|
74
|
+
*
|
|
75
|
+
* ## Классы отказа
|
|
76
|
+
*
|
|
77
|
+
* | Класс | Статус | Признак | Лечение | Доказательство |
|
|
78
|
+
* |---|---|---|---|---|
|
|
79
|
+
* | подделка | ЗАКРЫТ | … | … | tests/webhooks/test_signature.py |
|
|
80
|
+
* | повтор | ЗАКРЫТ | … | … | tests/webhooks/test_redelivery.py |
|
|
81
|
+
* | перестановка | ЗАКРЫТ | … | … | tests/webhooks/test_ordering.py |
|
|
82
|
+
*
|
|
83
|
+
* Exit codes — three, and the third is the point:
|
|
84
|
+
* 0 all three classes closed; the repeat key, its origin, its store and its exclusion are named
|
|
85
|
+
* and none of them is one of the forms that provably cannot work; the signature is checked over
|
|
86
|
+
* the raw body before parsing, constant-time, inside a bounded window; ordering is answered
|
|
87
|
+
* 1 a defect is PROVEN and named
|
|
88
|
+
* 2 THE CHECK DID NOT RUN — no contract, an unrecognised value, an unparseable number, or the
|
|
89
|
+
* legitimate answers «входящих вебхуков нет» / «проверка НЕ ВЫПОЛНЕНА, причина такая-то»
|
|
90
|
+
*
|
|
91
|
+
* A checker that answers "clean" when it could not look converts an unknown into a reassurance —
|
|
92
|
+
* which for this feature is a partner paid twice out of your own margin.
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
const fs = require('node:fs');
|
|
96
|
+
const path = require('node:path');
|
|
97
|
+
|
|
98
|
+
const CONTRACT = path.join('docs', 'webhook-contract.md');
|
|
99
|
+
|
|
100
|
+
/** Does anything call INTO this product at all? CLOSED — `нет` is legitimate and exits 2, never 0:
|
|
101
|
+
* there is nothing to check, and «нечего проверять» must not be spelled like «проверено». */
|
|
102
|
+
const INCOMING = { 'ДА': true, 'НЕТ': false };
|
|
103
|
+
|
|
104
|
+
/** Was one event actually delivered TWICE against the running handler? CLOSED, and the negative
|
|
105
|
+
* answer is honest, not a failure: CFG-I4 of `honest-configuration` — an unreachable truth yields
|
|
106
|
+
* UNKNOWN, never a plausible value. */
|
|
107
|
+
const RUN_STATUS = { 'ВЫПОЛНЕНА': 'done', 'НЕ ВЫПОЛНЕНА': 'not-done' };
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Why the redelivery run did not happen. CLOSED list — free text is not a reason here, because the
|
|
111
|
+
* entire value of the list is that each entry names a DIFFERENT repair:
|
|
112
|
+
* no-provider — connect the sender's test mode · no-test-harness — build the replay fixture
|
|
113
|
+
* not-implemented — write the handler, then re-check · out-of-scope — decide and record it
|
|
114
|
+
*/
|
|
115
|
+
const REASONS = ['no-provider', 'no-test-harness', 'not-implemented', 'out-of-scope'];
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Where the repeat key comes from. CLOSED, and the wrong answer is the whole first half of the
|
|
119
|
+
* feature: a value the RECEIVER makes up at receive time is different on every delivery, so it can
|
|
120
|
+
* never recognise a second delivery of the same event.
|
|
121
|
+
*/
|
|
122
|
+
const KEY_SOURCE = { 'СОБЫТИЕ-ОТПРАВИТЕЛЯ': 'sender', 'СГЕНЕРИРОВАН-ПОЛУЧАТЕЛЕМ': 'receiver' };
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* How a second write is excluded. CLOSED, and `проверка-перед-вставкой` is a PROVEN defect rather
|
|
126
|
+
* than a weaker option: two retries arrive CONCURRENTLY, both SELECT and both see nothing, both
|
|
127
|
+
* INSERT. The read-then-write dedup passes every single-threaded test and fails on exactly the real
|
|
128
|
+
* double delivery it was written for.
|
|
129
|
+
*/
|
|
130
|
+
const EXCLUSION = {
|
|
131
|
+
'УНИКАЛЬНЫЙ-ИНДЕКС': 'atomic',
|
|
132
|
+
'АТОМАРНАЯ-ВСТАВКА': 'atomic',
|
|
133
|
+
'ПРОВЕРКА-ПЕРЕД-ВСТАВКОЙ': 'race',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** What the signature is computed over. The signature covers exact BYTES; a parse plus re-serialise
|
|
137
|
+
* changes them (key order, spacing, unicode escapes), so verification can never succeed — and the
|
|
138
|
+
* usual "fix" for that is to switch verification off. */
|
|
139
|
+
const SIGNED_OVER = { 'СЫРОЕ-ТЕЛО': 'raw', 'РАЗОБРАННОЕ-ТЕЛО': 'reparsed' };
|
|
140
|
+
|
|
141
|
+
/** When it is verified. `после-разбора` means your parser — and often your business logic — already
|
|
142
|
+
* ran on unauthenticated attacker-controlled input. */
|
|
143
|
+
const SIGN_WHEN = { 'ДО-РАЗБОРА': 'before', 'ПОСЛЕ-РАЗБОРА': 'after' };
|
|
144
|
+
|
|
145
|
+
/** How the two digests are compared. A byte-by-byte compare that returns early leaks, through
|
|
146
|
+
* timing, how long a prefix matched — enough to reconstruct a valid signature. */
|
|
147
|
+
const COMPARISON = { 'ПОСТОЯННОЕ-ВРЕМЯ': 'constant', 'ОБЫЧНОЕ': 'naive' };
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* How ordering is answered. CLOSED, and `гарантирован-отправителем` is a PROVEN defect: at-least-once
|
|
151
|
+
* delivery with independent retries has no order, the providers' own documentation says so, and the
|
|
152
|
+
* handler that relies on it breaks precisely on the retry that also produces the duplicate.
|
|
153
|
+
*/
|
|
154
|
+
const ORDER = {
|
|
155
|
+
'ВЕРСИЯ-ИЗ-СОБЫТИЯ': 'versioned',
|
|
156
|
+
'ПЕРЕСТАНОВОЧЕН': 'commutative',
|
|
157
|
+
'ГАРАНТИРОВАН-ОТПРАВИТЕЛЕМ': 'assumed',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The three failure classes, as a CLOSED and MANDATORY set.
|
|
162
|
+
*
|
|
163
|
+
* Mandatory is the load-bearing half. Two classes out of three answered is not an unknown — it is a
|
|
164
|
+
* PROVEN omission whose name we can print. A handler that verifies signatures and credits the same
|
|
165
|
+
* commission twice loses exactly as much money as one that never checked a signature.
|
|
166
|
+
*/
|
|
167
|
+
const CLASSES = ['подделка', 'повтор', 'перестановка'];
|
|
168
|
+
|
|
169
|
+
/** Per-class verdict. CLOSED: an unmapped spelling is refused and the recognised ones are printed. */
|
|
170
|
+
const CLASS_STATUS = { 'ЗАКРЫТ': 'closed', 'НЕ ЗАКРЫТ': 'open' };
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Key names that cannot distinguish "one event delivered twice" from "two genuine events" — and the
|
|
174
|
+
* list is deliberately SHORT, because a wide blacklist refuses correct configurations and a check
|
|
175
|
+
* that refuses the correct configuration gets switched off.
|
|
176
|
+
*
|
|
177
|
+
* The two directions fail differently and both cost money:
|
|
178
|
+
* a value that CHANGES per delivery (a timestamp, the moment of receipt) — the repeat looks new,
|
|
179
|
+
* and the commission is credited twice;
|
|
180
|
+
* a value SHARED by two genuine events (the amount, the total) — the second real payment is
|
|
181
|
+
* swallowed as a duplicate, and the partner is never paid at all.
|
|
182
|
+
*/
|
|
183
|
+
const BAD_KEY = [
|
|
184
|
+
[/(^|[^a-zа-яё0-9_])(timestamp|created_?at|created|received_?at|now|время|метка[ _-]?времени)([^a-zа-яё0-9_]|$)/i,
|
|
185
|
+
'меняется при КАЖДОЙ доставке, поэтому повтор выглядит новым событием'],
|
|
186
|
+
[/(^|[^a-zа-яё0-9_])(amount|sum|total|сумма|итого)([^a-zа-яё0-9_]|$)/i,
|
|
187
|
+
'совпадает у ДВУХ настоящих событий, поэтому второй законный платёж будет съеден как дубль'],
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Stores that do not survive what a webhook endpoint routinely survives.
|
|
192
|
+
*
|
|
193
|
+
* BOUNDED ON PURPOSE: `in-memory` is NOT here. Redis is an in-memory store and is a perfectly good
|
|
194
|
+
* home for this key — it is shared between workers and it outlives a request. What fails is a store
|
|
195
|
+
* INSIDE the application process: a dict, a module-level set, a per-process cache. It is empty after
|
|
196
|
+
* every restart and invisible to the second replica, so the same event lands twice the moment you
|
|
197
|
+
* scale to two workers — which is to say, in production and not in development.
|
|
198
|
+
*/
|
|
199
|
+
// NOTE the Cyrillic character classes below, and do not "simplify" them back to `\w`: in JavaScript
|
|
200
|
+
// `\w` is ASCII-only, so `глобальн\w*` matches nothing in `глобальный словарь` and the whole second
|
|
201
|
+
// mask is DEAD while looking correct. MEASURED 2026-09-01: the first run of P9 passed a contract
|
|
202
|
+
// declaring `глобальный словарь handled_ids` as the store with exit 0.
|
|
203
|
+
const BAD_STORE = [
|
|
204
|
+
/(^|[^a-zа-яё])(память процесса|в памяти процесса|in-?process|process memory)/i,
|
|
205
|
+
/(^|[^a-zа-яё])(локальн[а-яё]*\s+переменн[а-яё]*|глобальн[а-яё]*\s+(?:словар[а-яё]*|множеств[а-яё]*|переменн[а-яё]*))/i,
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The freshness window has an upper bound, and the bound is part of the property.
|
|
210
|
+
*
|
|
211
|
+
* The window exists to limit how long a captured-but-genuine request stays replayable. A window
|
|
212
|
+
* measured in hours does not limit it — it merely writes the limit down. Providers' own tolerances
|
|
213
|
+
* sit around five minutes; an hour is already generous, and everything under it passes untouched.
|
|
214
|
+
*/
|
|
215
|
+
const MAX_WINDOW_S = 3600;
|
|
216
|
+
|
|
217
|
+
function say(s) { process.stdout.write(s + '\n'); }
|
|
218
|
+
|
|
219
|
+
/** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
|
|
220
|
+
function cannotCheck(reason, hint) {
|
|
221
|
+
say('⚠️ проверка НЕ выполнена: ' + reason);
|
|
222
|
+
if (hint) say(' ' + hint);
|
|
223
|
+
process.exit(2);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Exit 1 with the defect NAMED. A violation that cannot be named is a 2, not a 1. */
|
|
227
|
+
function proven(title, lines, tail) {
|
|
228
|
+
say('❌ ' + title);
|
|
229
|
+
for (const line of lines || []) say(' • ' + line);
|
|
230
|
+
if (tail) say(' ' + tail);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The value of a `**Label:** value` header line, or null when the label is absent entirely.
|
|
236
|
+
* An EMPTY value is returned as '' and is never collapsed into "absent" — those are different
|
|
237
|
+
* mistakes with different repairs (`honest-configuration` CFG-I2).
|
|
238
|
+
*/
|
|
239
|
+
function header(text, label) {
|
|
240
|
+
const re = new RegExp('^\\s*\\*\\*' + label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
241
|
+
+ ':?\\*\\*\\s*:?(.*)$', 'im');
|
|
242
|
+
const m = re.exec(text);
|
|
243
|
+
return m ? m[1].trim().replace(/^[«"`]|[»"`]$/g, '').trim() : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A header value read against a CLOSED map, with both failure modes kept apart. */
|
|
247
|
+
function closedHeader(text, label, map, what) {
|
|
248
|
+
const raw = header(text, label);
|
|
249
|
+
if (raw === null) {
|
|
250
|
+
cannotCheck('в контракте нет строки `**' + label + ':**`',
|
|
251
|
+
what + ' — допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
252
|
+
}
|
|
253
|
+
const key = raw.toUpperCase().replace(/\s+/g, ' ').trim();
|
|
254
|
+
if (!Object.prototype.hasOwnProperty.call(map, key)) {
|
|
255
|
+
cannotCheck('нераспознанное значение `' + label + '`: ' + (key === '' ? '(пусто)' : key),
|
|
256
|
+
'допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
257
|
+
}
|
|
258
|
+
return map[key];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A header that must merely be filled in — absent or empty is «не заполнено», not «нарушено». */
|
|
262
|
+
function requiredText(text, label, hint) {
|
|
263
|
+
const raw = header(text, label);
|
|
264
|
+
if (raw === null || raw === '' || /^[-—–]$/.test(raw) || /^\[.*\]$/.test(raw)) {
|
|
265
|
+
cannotCheck('в контракте нет строки `**' + label + ':**` (или она пуста / всё ещё шаблон)', hint);
|
|
266
|
+
}
|
|
267
|
+
return raw;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The failure-class table, as the contract records it.
|
|
272
|
+
*
|
|
273
|
+
* A row is a markdown table row whose FIRST cell is one of the three class names. The template ships
|
|
274
|
+
* example rows, so a row whose evidence cell is still a bracketed placeholder is a TEMPLATE row and
|
|
275
|
+
* is read as an EMPTY proof — never as a filled-in one.
|
|
276
|
+
*/
|
|
277
|
+
function classRows(text) {
|
|
278
|
+
const rows = [];
|
|
279
|
+
for (const raw of text.split('\n')) {
|
|
280
|
+
const line = raw.trim();
|
|
281
|
+
if (!line.startsWith('|')) continue;
|
|
282
|
+
const cells = line.split('|').map((c) => c.trim());
|
|
283
|
+
const name = (cells[1] || '').toLowerCase();
|
|
284
|
+
if (!CLASSES.includes(name)) continue;
|
|
285
|
+
const evidence = cells[5] || '';
|
|
286
|
+
rows.push({
|
|
287
|
+
name,
|
|
288
|
+
status: (cells[2] || '').toUpperCase().replace(/\s+/g, ' ').trim(),
|
|
289
|
+
evidence: /^\[.*\]$/.test(evidence) ? '' : evidence,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return rows;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Every token in an evidence cell that LOOKS like a file: `dir/name.ext`, optionally followed by
|
|
297
|
+
* `::test_name` or `:42`. Prose alone yields nothing, which the caller reads as "a claim with no
|
|
298
|
+
* proof behind it" — the same defect as a widget proof that names no address.
|
|
299
|
+
*/
|
|
300
|
+
function evidenceFiles(cell) {
|
|
301
|
+
const out = [];
|
|
302
|
+
for (const m of String(cell || '').matchAll(/[A-Za-z0-9_./\\-]*[A-Za-z0-9_-]\.[A-Za-z0-9_]{1,10}/g)) {
|
|
303
|
+
const token = m[0].split('::')[0].split('#')[0].replace(/[.,;)]+$/, '');
|
|
304
|
+
if (/\.(md|txt)$/i.test(token)) continue; // a document is not a test
|
|
305
|
+
if (token.includes('/') || token.includes('\\') || /^test|test$|_test|spec/i.test(token)) {
|
|
306
|
+
out.push(token.split('\\').join('/'));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return [...new Set(out)];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function main() {
|
|
313
|
+
const root = process.argv[2] || '.';
|
|
314
|
+
try { if (!fs.statSync(root).isDirectory()) cannotCheck('это не каталог: ' + root); }
|
|
315
|
+
catch { cannotCheck('путь не существует: ' + root); }
|
|
316
|
+
|
|
317
|
+
const abs = path.join(root, CONTRACT);
|
|
318
|
+
let text;
|
|
319
|
+
try {
|
|
320
|
+
if (!fs.statSync(abs).isFile()) cannotCheck(CONTRACT + ' существует, но это не файл');
|
|
321
|
+
text = fs.readFileSync(abs, 'utf-8');
|
|
322
|
+
} catch (e) {
|
|
323
|
+
if (e && e.code === 'ENOENT') {
|
|
324
|
+
cannotCheck('нет файла ' + CONTRACT,
|
|
325
|
+
'это значит, что вопрос о входящих вебхуках НЕ ЗАДАВАЛСЯ — а НЕ что их нет; '
|
|
326
|
+
+ 'продукт без вебхуков отвечает `**Входящие вебхуки:** нет`, и это законный ответ');
|
|
327
|
+
}
|
|
328
|
+
cannotCheck('не читается ' + CONTRACT + ': ' + ((e && e.message) || e));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 1. Does anything call in at all? «нет» is legitimate and has nothing to check → 2.
|
|
332
|
+
const incoming = closedHeader(text, 'Входящие вебхуки', INCOMING,
|
|
333
|
+
'без этой строки нельзя отличить «вебхуков нет» от «про вебхуки забыли»');
|
|
334
|
+
if (!incoming) {
|
|
335
|
+
cannotCheck('контракт говорит «Входящие вебхуки: нет» — чужие системы в продукт не звонят',
|
|
336
|
+
'это законный ответ, а не нарушение; проверять нечего, поэтому не 0 и не 1');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
requiredText(text, 'Отправитель',
|
|
340
|
+
'кто присылает события — без имени отправителя неизвестна и схема подписи, которую вы проверяете');
|
|
341
|
+
|
|
342
|
+
// 2. Was one event actually delivered twice? A named refusal is honest and exits 2.
|
|
343
|
+
const run = closedHeader(text, 'Проверка повторной доставкой', RUN_STATUS,
|
|
344
|
+
'без этой строки «не проверяли» неотличимо от «проверили»');
|
|
345
|
+
if (run === 'not-done') {
|
|
346
|
+
const raw = header(text, 'Причина');
|
|
347
|
+
if (raw === null || raw === '') {
|
|
348
|
+
cannotCheck('проверка НЕ ВЫПОЛНЕНА без строки `**Причина:**`',
|
|
349
|
+
'причина обязательна и берётся из закрытого списка: ' + REASONS.join(' | ')
|
|
350
|
+
+ ' — каждая означает СВОЙ ремонт');
|
|
351
|
+
}
|
|
352
|
+
const picked = REASONS.filter((r) => new RegExp('(^|[^a-z-])' + r + '([^a-z-]|$)', 'i').test(raw));
|
|
353
|
+
if (picked.length !== 1) {
|
|
354
|
+
cannotCheck('причина «' + raw + '» не из закрытого списка (или названо сразу несколько)',
|
|
355
|
+
'допустимы ровно: ' + REASONS.join(' | '));
|
|
356
|
+
}
|
|
357
|
+
cannotCheck('повторная доставка НЕ ВОСПРОИЗВОДИЛАСЬ, причина: ' + picked[0],
|
|
358
|
+
'честное «неизвестно», а не «обработчик идемпотентен»; до закрытия причины ни один из трёх '
|
|
359
|
+
+ 'классов отказа не проверен');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 3. ПОВТОР. The key itself: named, from the sender, stored where it survives, excluded atomically.
|
|
363
|
+
const key = requiredText(text, 'Ключ повторности',
|
|
364
|
+
'назовите ПОЛЕ, по которому событие узнаётся вторично (например `event.id`) — '
|
|
365
|
+
+ '«сделаем идемпотентно» это не ключ, а намерение');
|
|
366
|
+
for (const [pattern, why] of BAD_KEY) {
|
|
367
|
+
if (pattern.test(key)) {
|
|
368
|
+
proven('ключ повторности не различает повтор и новое событие', ['**Ключ повторности:** ' + key],
|
|
369
|
+
why + '. Ключ обязан быть ТОЖДЕСТВОМ события у отправителя — тем полем, которое при '
|
|
370
|
+
+ 'повторной доставке ТО ЖЕ САМОЕ, а у двух разных событий РАЗНОЕ.');
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const source = closedHeader(text, 'Источник ключа', KEY_SOURCE,
|
|
375
|
+
'ключ приходит В СОБЫТИИ или выдумывается на приёме');
|
|
376
|
+
if (source === 'receiver') {
|
|
377
|
+
proven('ключ повторности генерирует ПОЛУЧАТЕЛЬ', ['**Ключ повторности:** ' + key],
|
|
378
|
+
'значение, придуманное в момент приёма, различно на каждой доставке — им нельзя узнать '
|
|
379
|
+
+ 'повтор в принципе. Возьмите идентификатор события у отправителя (`event.id`, '
|
|
380
|
+
+ '`Idempotency-Key`), он одинаков во всех попытках доставки одного события.');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const store = requiredText(text, 'Хранилище ключа',
|
|
384
|
+
'где ключ лежит: таблица и колонка, ключ в Redis — покажите МЕСТО, а не намерение');
|
|
385
|
+
for (const pattern of BAD_STORE) {
|
|
386
|
+
if (pattern.test(store)) {
|
|
387
|
+
proven('ключ хранится ВНУТРИ процесса', ['**Хранилище ключа:** ' + store],
|
|
388
|
+
'такой стор пуст после каждого рестарта и невидим второй реплике: как только воркеров '
|
|
389
|
+
+ 'станет два, одно событие обработают оба. Нужен стор, общий для всех воркеров и '
|
|
390
|
+
+ 'переживающий рестарт (таблица в базе, ключ в Redis).');
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const exclusion = closedHeader(text, 'Механизм исключения', EXCLUSION,
|
|
395
|
+
'чем именно исключается ВТОРАЯ запись');
|
|
396
|
+
if (exclusion === 'race') {
|
|
397
|
+
proven('исключение повтора построено на «прочитать, потом записать»', [
|
|
398
|
+
'**Механизм исключения:** проверка-перед-вставкой',
|
|
399
|
+
],
|
|
400
|
+
'две попытки доставки приходят ОДНОВРЕМЕННО: обе читают и обе не находят ключ, обе пишут — '
|
|
401
|
+
+ 'и комиссия начислена дважды. Такая дедупликация проходит любой однопоточный тест и падает '
|
|
402
|
+
+ 'ровно на той настоящей двойной доставке, ради которой написана. Нужна атомарная операция: '
|
|
403
|
+
+ 'уникальный индекс на колонке ключа и вставка, чей конфликт и есть ответ «уже обработано».');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 4. ПОДДЕЛКА. Verified over the raw bytes, before parsing, in constant time, inside a window.
|
|
407
|
+
const signedOver = closedHeader(text, 'Что подписано', SIGNED_OVER,
|
|
408
|
+
'подпись считается по БАЙТАМ тела');
|
|
409
|
+
if (signedOver === 'reparsed') {
|
|
410
|
+
proven('подпись сверяется с ПЕРЕСОБРАННЫМ телом', ['**Что подписано:** разобранное-тело'],
|
|
411
|
+
'разбор и обратная сборка меняют байты (порядок ключей, пробелы, экранирование), поэтому '
|
|
412
|
+
+ 'подпись не совпадёт НИКОГДА — а обычное «лечение» этого симптома состоит в том, чтобы '
|
|
413
|
+
+ 'выключить проверку. Сохраняйте сырое тело запроса и считайте подпись по нему.');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const when = closedHeader(text, 'Когда проверяется подпись', SIGN_WHEN,
|
|
417
|
+
'до разбора тела или после');
|
|
418
|
+
if (when === 'after') {
|
|
419
|
+
proven('подпись проверяется ПОСЛЕ разбора тела', ['**Когда проверяется подпись:** после-разбора'],
|
|
420
|
+
'к этому моменту ваш разборщик — а часто и бизнес-логика — уже отработали на данных, которые '
|
|
421
|
+
+ 'прислал кто угодно. Проверка подписи это ПЕРВОЕ действие обработчика, до любого разбора.');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const comparison = closedHeader(text, 'Сравнение подписи', COMPARISON,
|
|
425
|
+
'обычное сравнение строк выдаёт длину совпавшего префикса временем ответа');
|
|
426
|
+
if (comparison === 'naive') {
|
|
427
|
+
proven('подписи сравниваются обычным сравнением', ['**Сравнение подписи:** обычное'],
|
|
428
|
+
'сравнение, выходящее на первом несовпавшем байте, сообщает временем ответа, какой длины '
|
|
429
|
+
+ 'префикс угадан — по этому каналу подпись подбирается побайтно. Нужна функция постоянного '
|
|
430
|
+
+ 'времени (`hmac.compare_digest`, `crypto.timingSafeEqual`).');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const rawWindow = requiredText(text, 'Окно свежести (секунды)',
|
|
434
|
+
'сколько секунд метке времени запроса позволено отстоять от текущего момента');
|
|
435
|
+
if (/^(нет|no|none|отсутствует)$/i.test(rawWindow)) {
|
|
436
|
+
proven('окна свежести нет', ['**Окно свежести (секунды):** ' + rawWindow],
|
|
437
|
+
'корректно подписанный запрос, перехваченный однажды, остаётся годным ВЕЧНО: его можно '
|
|
438
|
+
+ 'переиграть через месяц и получить второе начисление. Подпись обязана покрывать метку '
|
|
439
|
+
+ 'времени, а обработчик — отвергать запрос старше окна.');
|
|
440
|
+
}
|
|
441
|
+
const window = Number(String(rawWindow).replace(',', '.').replace(/\s*(с|сек\w*|s|sec\w*)\s*$/i, ''));
|
|
442
|
+
if (!Number.isFinite(window)) {
|
|
443
|
+
cannotCheck('`Окно свежести (секунды)` не разбирается как число: ' + rawWindow,
|
|
444
|
+
'нужно число секунд, например `300`');
|
|
445
|
+
}
|
|
446
|
+
if (window <= 0) {
|
|
447
|
+
proven('окно свежести не ограничивает ничего', ['**Окно свежести (секунды):** ' + rawWindow],
|
|
448
|
+
'ноль или отрицательное окно означает, что проверки возраста запроса нет.');
|
|
449
|
+
}
|
|
450
|
+
if (window > MAX_WINDOW_S) {
|
|
451
|
+
proven('окно свежести шире часа', ['**Окно свежести (секунды):** ' + window],
|
|
452
|
+
'окно существует, чтобы ОГРАНИЧИТЬ срок годности перехваченного запроса; окно в часы его не '
|
|
453
|
+
+ 'ограничивает, а лишь записывает. Собственные допуски отправителей — около пяти минут; '
|
|
454
|
+
+ 'всё до ' + MAX_WINDOW_S + ' с проходит без замечаний.');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// 5. ПЕРЕСТАНОВКА. The same retry topology, the third way it costs money.
|
|
458
|
+
const order = closedHeader(text, 'Порядок событий', ORDER,
|
|
459
|
+
'обработчик опирается на порядок или нет');
|
|
460
|
+
if (order === 'assumed') {
|
|
461
|
+
proven('обработчик полагается на порядок доставки', ['**Порядок событий:** гарантирован-отправителем'],
|
|
462
|
+
'порядок НЕ гарантирован: попытки доставки независимы, поэтому событие, случившееся раньше, '
|
|
463
|
+
+ 'может приехать позже — и перезаписать более новое состояние. Это тот же ретрай, который '
|
|
464
|
+
+ 'даёт дубли, поэтому чинить дубли и верить в порядок нельзя одновременно. Либо применяйте '
|
|
465
|
+
+ 'событие только если его версия/время новее уже применённой (`версия-из-события`), либо '
|
|
466
|
+
+ 'сделайте обработчик перестановочным и объявите это.');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// 6. All three classes, each closed, each proof pointing at a test file THAT EXISTS.
|
|
470
|
+
const rows = classRows(text);
|
|
471
|
+
const seen = rows.map((r) => r.name);
|
|
472
|
+
const dupes = [...new Set(seen.filter((n, i) => seen.indexOf(n) !== i))];
|
|
473
|
+
if (dupes.length) {
|
|
474
|
+
cannotCheck('в таблице классов повторяются строки: ' + dupes.join(', '),
|
|
475
|
+
'один класс — одна строка; иначе один зачёт закрывает сразу два разных вопроса');
|
|
476
|
+
}
|
|
477
|
+
const bad = rows.filter((r) => !Object.prototype.hasOwnProperty.call(CLASS_STATUS, r.status));
|
|
478
|
+
if (bad.length) {
|
|
479
|
+
cannotCheck('нераспознанный статус класса: '
|
|
480
|
+
+ bad.map((r) => r.name + ' → ' + (r.status || '(пусто)')).join(', '),
|
|
481
|
+
'допустимы ровно: ' + Object.keys(CLASS_STATUS).join(' | '));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const missing = CLASSES.filter((c) => !seen.includes(c));
|
|
485
|
+
if (missing.length) {
|
|
486
|
+
proven('класс отказа не назван вовсе (' + missing.length + ' из ' + CLASSES.length + ')', missing,
|
|
487
|
+
'три класса это ЗАКРЫТЫЙ и ОБЯЗАТЕЛЬНЫЙ набор: обработчик, проверяющий подпись и начисляющий '
|
|
488
|
+
+ 'комиссию дважды, теряет ровно столько же денег, сколько тот, что подпись не проверял. '
|
|
489
|
+
+ 'Пропуск здесь — доказанная потеря, а не неизвестность.');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const open = rows.filter((r) => CLASS_STATUS[r.status] === 'open');
|
|
493
|
+
if (open.length) {
|
|
494
|
+
proven('проверка объявлена ВЫПОЛНЕННОЙ, но класс остался НЕ ЗАКРЫТ', open.map((r) => r.name),
|
|
495
|
+
'либо закройте класс, либо объявите всю проверку НЕ ВЫПОЛНЕННОЙ с причиной — частичный '
|
|
496
|
+
+ 'прогон под вывеской выполненного и есть ложная квитанция.');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const noFile = [];
|
|
500
|
+
const absent = [];
|
|
501
|
+
for (const row of rows) {
|
|
502
|
+
const files = evidenceFiles(row.evidence);
|
|
503
|
+
if (!files.length) { noFile.push(row.name); continue; }
|
|
504
|
+
const found = files.filter((f) => {
|
|
505
|
+
try { return fs.statSync(path.join(root, f)).isFile(); } catch { return false; }
|
|
506
|
+
});
|
|
507
|
+
if (!found.length) absent.push(row.name + ' → ' + files.join(', '));
|
|
508
|
+
}
|
|
509
|
+
if (noFile.length) {
|
|
510
|
+
proven('класс объявлен ЗАКРЫТЫМ, но доказательство не называет файл теста', noFile,
|
|
511
|
+
'закрывает этот класс не решение, а ПРОГОН: событие, доставленное дважды, и утверждение, '
|
|
512
|
+
+ 'что начисление одно. Назовите файл теста — «проверено вручную» и «не проверяли» пишутся '
|
|
513
|
+
+ 'одинаково.');
|
|
514
|
+
}
|
|
515
|
+
if (absent.length) {
|
|
516
|
+
proven('названный файл теста не существует', absent,
|
|
517
|
+
'квитанция указывает на пустоту: путь искали от корня проекта и не нашли. Это доказанная '
|
|
518
|
+
+ 'потеря, а не неизвестность — мы посмотрели.');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
say('✅ все ' + CLASSES.length + ' классов отказа закрыты: ключ повторности `' + key
|
|
522
|
+
+ '` из события отправителя, ' + 'исключение атомарное, подпись по сырому телу до разбора '
|
|
523
|
+
+ '(окно ' + window + ' с), порядок — ' + header(text, 'Порядок событий'));
|
|
524
|
+
say(' Ограничение: это доказывает, что РЕШЕНИЯ приняты и записаны, а названные файлы тестов '
|
|
525
|
+
+ 'существуют — а НЕ что тест доставляет одно событие дважды и утверждает одно начисление, и '
|
|
526
|
+
+ 'не что уникальный индекс существует в развёрнутой схеме. Это доказывает только прогон.');
|
|
527
|
+
process.exit(0);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
main();
|
|
532
|
+
} catch (err) {
|
|
533
|
+
// Even an unexpected failure must not read as "clean".
|
|
534
|
+
cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
|
|
535
|
+
}
|
|
@@ -244,8 +244,8 @@ function parseExpectedToolkit() {
|
|
|
244
244
|
skillsExpected: 10,
|
|
245
245
|
commandsExpected: 11,
|
|
246
246
|
agentsExpected: 4, // pre-shipped only (project agents are extra)
|
|
247
|
-
rulesExpected:
|
|
248
|
-
hooksExpected:
|
|
247
|
+
rulesExpected: 13, // pre-shipped only (project rules are extra)
|
|
248
|
+
hooksExpected: 24, // 4 event hooks + statusline + state-update + writer + 14 checks + 1 capture
|
|
249
249
|
};
|
|
250
250
|
}
|
|
251
251
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Встраиваемый виджет: он живёт на ЧУЖОЙ странице
|
|
2
|
+
|
|
3
|
+
Действует, когда поставляемое — кусок, который клиент вставляет к себе: сборщик отзывов, чат-пузырь,
|
|
4
|
+
калькулятор. Там виджет есть ПРОДУКТ ЦЕЛИКОМ: не работает у клиента — не работает ничего.
|
|
5
|
+
|
|
6
|
+
## Своя страница — не проверка
|
|
7
|
+
|
|
8
|
+
**Проверка виджета ОБЯЗАНА выполняться на странице ЧУЖОГО origin** (origin = схема+хост+порт,
|
|
9
|
+
браузерное определение «другого сайта»). Другого порта достаточно: `http://localhost:8099` чужой
|
|
10
|
+
для `http://localhost:3000` и даёт настоящий предполётный запрос.
|
|
11
|
+
|
|
12
|
+
«Открыли свою демо-страницу, виджет отрисовался» — НЕ проверка. На своей странице origin совпадает
|
|
13
|
+
(предполётного запроса нет вовсе), чужого CSS нет, чужой политики безопасности нет: ни один из трёх
|
|
14
|
+
классов отказа там не может проявиться. Зелёный результат получен на единственной странице, чьё
|
|
15
|
+
поведение не имеет значения.
|
|
16
|
+
|
|
17
|
+
Механизм: **условия отказа принадлежат чужой странице, а тестируется своя.** Тот же дефект даёт
|
|
18
|
+
подтверждение развёртывания обращением к `localhost` — проверка обязана пользоваться адресом,
|
|
19
|
+
который система ВЫДАЛА, а не тем, который знает сама.
|
|
20
|
+
|
|
21
|
+
## Три класса отказа
|
|
22
|
+
|
|
23
|
+
Набор ЗАКРЫТЫЙ и ОБЯЗАТЕЛЬНЫЙ: виджет, переживший чужой CSS и умерший на чужом CSP, у клиента не
|
|
24
|
+
работает. Первый и третий отказывают при полностью зелёной проверке у вас.
|
|
25
|
+
|
|
26
|
+
| Класс | Признак у клиента | Лечение |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `перекрёстный-запрос` | виджет виден, данные не идут; в консоли клиента `blocked by CORS policy`, а в вашем журнале запрос ЕСТЬ и отвечен 200 — ответ отбросил браузер | отвечать `Access-Control-Allow-Origin` с origin ХОЗЯИНА из явного списка; `OPTIONS` → 204 с `Allow-Methods`/`Allow-Headers`. С `credentials` джокер `*` НЕЛЕГАЛЕН, браузер отклонит |
|
|
29
|
+
| `протечка-стилей` | вёрстка едет только у клиента и у каждого по-своему: чужие reset, `* { box-sizing }`, `img { width: 100% }`, война `z-index`. Обратное направление — тоже отказ: ваши глобальные селекторы ломают хозяйскую страницу | изоляция ГРАНИЦЕЙ, а не специфичностью: Shadow DOM либо iframe; внутри `all: initial` на корне и свои единицы вместо унаследованных |
|
|
30
|
+
| `политика-безопасности` | виджет не появляется ВООБЩЕ; в консоли `Refused to load … violates the following Content Security Policy directive` | никаких инлайновых `<script>`/`<style>` (не требовать `unsafe-inline`); опубликовать точный список директив, который хозяин обязан разрешить (`script-src`, `connect-src`, `frame-src`, `img-src`); проверять ПОД ограничительным CSP |
|
|
31
|
+
|
|
32
|
+
**Оснастка, воспроизводящая все три:** страница по HTTP на ДРУГОМ порту, вставляющая виджет по
|
|
33
|
+
ПУБЛИЧНОМУ адресу, который выдало развёртывание, отдающая ограничительный `Content-Security-Policy`
|
|
34
|
+
и несущая враждебный CSS.
|
|
35
|
+
|
|
36
|
+
## Артефакт и ворота
|
|
37
|
+
|
|
38
|
+
`docs/embed-contract.md` — объявление плюс квитанция, по строке на каждый класс, и в каждой строке
|
|
39
|
+
ДОКАЗАТЕЛЬСТВО с адресом. Точная форма полей и закрытые списки значений — в шапке проверки:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
node .claude/hooks/check-embed-contract.cjs .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`0` все три класса проверены и каждое доказательство называет ЧУЖОЙ origin · `1` дефект ДОКАЗАН и
|
|
46
|
+
назван (проверка на своём origin, страница `file://`, пропущенный класс, `НЕ ПРОВЕРЕН` под вывеской
|
|
47
|
+
выполненной проверки, доказательство без адреса, `credentials` вместе с `*`) · `2` **проверка НЕ
|
|
48
|
+
ВЫПОЛНЕНА** (нет контракта, нераспознанное значение, неразбираемый адрес, либо законные ответы «не
|
|
49
|
+
встраивается» и «НЕ ВЫПОЛНЕНА с причиной»). Код `2` никогда не значит «всё в порядке».
|
|
50
|
+
|
|
51
|
+
## Честная разметка слоя
|
|
52
|
+
|
|
53
|
+
Слои — по [`cost-of-detection-ladder`](./cost-of-detection-ladder.md).
|
|
54
|
+
|
|
55
|
+
**Слой 1 (детерминированно):** доказательство называет origin, и он не ваш; все три класса названы;
|
|
56
|
+
пара `credentials` + `*` отвергнута. Это проверка ДЕКЛАРАЦИИ.
|
|
57
|
+
|
|
58
|
+
**Слой 3–4 (остаётся суждением, и сузить нечем):** цел ли виджет на враждебной странице; те ли это
|
|
59
|
+
директивы CSP, которые нужны хозяину; не отражает ли список разрешённых origin произвольный origin
|
|
60
|
+
обратно. Детерминированной половины здесь быть НЕ МОЖЕТ по названной причине: у пакета ноль
|
|
61
|
+
зависимостей и нет браузера, а вердикт выносит только настоящий браузер на настоящей чужой странице.
|
|
62
|
+
|
|
63
|
+
**Нового семейства идентификаторов НЕТ, и это решение.** `FR-LOOK-nnn` отвечает «снятое с источника
|
|
64
|
+
доехало до спецификации?»; здесь снимать нечего — обязательство рождается из топологии доставки, а
|
|
65
|
+
не из внешности источника, и статусы `СНЯТ`/`НЕ ИЗМЕРЕНО` к нему неприменимы. Наблюдение о том, КАК
|
|
66
|
+
встраивается сам источник (iframe или Shadow DOM), — законная строка `FR-LOOK-nnn` оси `облик`; три
|
|
67
|
+
класса отказа — не она.
|
|
68
|
+
|
|
69
|
+
## Самопроверка
|
|
70
|
+
|
|
71
|
+
1. Назови origin страницы, где виджет проверяли. Совпал с origin виджета? Проверки не было.
|
|
72
|
+
2. Та страница отдавала CSP и враждебный CSS? Нет — проверены не те условия.
|
|
73
|
+
3. Адрес виджета там — тот, который ВЫДАЛО развёртывание, или тот, который ты знал заранее?
|