@ssheleg/sheleg-dev 0.6.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +101 -1
  3. package/SECURITY.md +190 -52
  4. package/bin/sheleg-dev.js +18 -0
  5. package/package.json +2 -2
  6. package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
  7. package/plugins/sheleg-dev/hooks/hooks.json +29 -0
  8. package/plugins/sheleg-dev/hooks/lib/moneygate.js +587 -0
  9. package/plugins/sheleg-dev/hooks/money-gate.js +68 -0
  10. package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +7 -3
  11. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +76 -0
  12. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +249 -0
  13. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/manifest.json +75 -0
  14. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-from-thank-you-page.json +32 -0
  15. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-server.json +26 -0
  16. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-pixel-browser.json +12 -0
  17. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/reference-emitter.mjs +129 -0
  18. package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +13 -1
  19. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +68 -0
  20. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
  21. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +1 -1
  22. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +110 -0
  23. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +404 -0
  24. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-partial.json +52 -0
  25. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-remainder.json +63 -0
  26. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-async-payment-failed.json +43 -0
  27. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-async-unpaid.json +43 -0
  28. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-paid.json +42 -0
  29. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-next-period.json +70 -0
  30. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-redelivery.json +70 -0
  31. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle.json +61 -0
  32. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-update-proration.json +90 -0
  33. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +172 -0
  34. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +315 -0
  35. package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +38 -2
  36. package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +43 -1
@@ -0,0 +1,76 @@
1
+ # Deduplication fixtures and the assertion pack
2
+
3
+ **Copy this whole directory into your repository.** It is the executable half of what
4
+ `SKILL.md` → *E-commerce* and `references/meta-linkedin.md` →
5
+ *Deduplication with the Conversions API* state in prose: the three payloads one purchase
6
+ produces, six assertions a correct emitter must pass, and a self-test that breaks the
7
+ emitter one rule at a time so you can watch every assertion fail.
8
+
9
+ ```bash
10
+ node assert-dedup-contract.mjs # 6 assertions against the reference emitter
11
+ node assert-dedup-contract.mjs --self-test # break one rule at a time; every assertion must go red
12
+ ```
13
+
14
+ No dependencies, no network, **no access token anywhere in this directory** — the sink
15
+ collects what would have been sent.
16
+
17
+ ## Contents
18
+
19
+ - [The fixtures](#the-fixtures)
20
+ - [The assertions](#the-assertions)
21
+ - [What each mutant deletes](#what-each-mutant-deletes)
22
+ - [Why the browser side is read from the fixture](#why-the-browser-side-is-read-from-the-fixture)
23
+ - [Placeholders](#placeholders)
24
+
25
+ ## The fixtures
26
+
27
+ | File | What it is |
28
+ |---|---|
29
+ | `purchase-pixel-browser.json` | the four arguments of the browser call: `fbq('track', 'Purchase', custom_data, { eventID })` |
30
+ | `purchase-capi-server.json` | the Conversions API request body the **webhook** sends for the same purchase |
31
+ | `purchase-capi-from-thank-you-page.json` | the body a thank-you-page-sourced emitter sends for a session whose charge never cleared — **the payload that must never exist**, kept so the assertion can name it |
32
+
33
+ The first two carry the same `event_id` and the byte-equal `event_name`, which is the
34
+ contract. The third is the defect, recorded rather than described: it was produced by
35
+ running the reference emitter with `webhook-sourced` removed.
36
+
37
+ ## The assertions
38
+
39
+ | Assertion | What a wrong emitter does |
40
+ |---|---|
41
+ | `pixel-and-capi-carry-one-event-id` | generates an id per emission, so Meta counts one purchase twice |
42
+ | `pixel-and-capi-carry-one-event-name` | sends `Purchase` from the browser and `purchase` from the server: a shared id that deduplicates nothing |
43
+ | `no-purchase-reported-before-the-charge-cleared` | reports the purchase from the one place that cannot know the money moved |
44
+ | `the-browser-event-is-kept` | drops the browser event, losing the click id, the consent state and the session |
45
+ | `identifiers-reach-the-server-hashed` | puts the address itself in `user_data.em` |
46
+ | `the-shipped-capi-fixture-is-what-a-correct-emitter-sends` | anything that changes the body — a ratchet, so the fixtures you copy are the reference output |
47
+
48
+ ## What each mutant deletes
49
+
50
+ | Rule | The code it removes | The integration that ships without it |
51
+ |---|---|---|
52
+ | `shared-event-id` | reading the id written once at session creation | `crypto.randomUUID()` at each emission |
53
+ | `exact-event-name` | the one `event_name` constant used by both sides | `'Purchase'` in the browser, a lowercased variant on the server |
54
+ | `webhook-sourced` | the gate that keeps the thank-you page from reporting | fires the conversion where the redirect lands |
55
+ | `keep-browser-event` | the pixel call | server-only, and blind to everything the browser carries |
56
+ | `hashed-identifiers` | the SHA-256 of the identifier | hands the raw address to the wrapper, which hashes whatever it is given |
57
+
58
+ ## Why the browser side is read from the fixture
59
+
60
+ `pixel-and-capi-carry-one-event-id` and `pixel-and-capi-carry-one-event-name` compare the
61
+ server's output against `purchase-pixel-browser.json` rather than against the pixel call the
62
+ same emitter just made. That is deliberate, and it is the correction of a first pass:
63
+ reading both sides out of one emitter meant deleting `keep-browser-event` also turned those
64
+ two assertions red, so neither the id rule nor the name rule was proven on its own. Two
65
+ mechanisms that both explain a passing fixture leave both untested. The fixture is the
66
+ browser's contract, held at a boundary the server does not control.
67
+
68
+ `--self-test` fails if any rule has no fixture that isolates it, so this stays true.
69
+
70
+ ## Placeholders
71
+
72
+ `usr_PLACEHOLDER_carol`, `prod_PLACEHOLDER_pro`, `fb.1.…PLACEHOLDERCLICKID`,
73
+ `203.0.113.10` (a documentation address). The email is `placeholder@example.invalid` and
74
+ the 64-hex string is its SHA-256 — a hash of a published placeholder, not of a person. No
75
+ pixel id, no dataset id and no access token appear here: the token is a runtime secret and
76
+ `references/performance-security.md` is where it belongs.
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ // The assertions a correct purchase emitter must pass, and the proof that each one can
3
+ // fail.
4
+ //
5
+ // node assert-dedup-contract.mjs # run them against the reference emitter
6
+ // node assert-dedup-contract.mjs --self-test # break the emitter one rule at a time
7
+ //
8
+ // `--self-test` is what makes this evidence: for every assertion it deletes the rule whose
9
+ // removal MUST turn that assertion red, and fails if the assertion still passes. It then
10
+ // prints which fixture ISOLATES each rule — because two rules that both explain a passing
11
+ // fixture mean neither is tested.
12
+ //
13
+ // The runner is duplicated from `stripe-billing/fixtures/assert-money-invariants.mjs` on
14
+ // purpose. A reader installs one skill, not six, so a shared import would resolve to
15
+ // nothing in the common case; this pack has to run from its own directory alone.
16
+ //
17
+ // No network, no filesystem writes, no clock, and no access token anywhere in this
18
+ // directory — the sink collects what WOULD have been sent.
19
+
20
+ import { readFileSync } from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
22
+ import path from 'node:path';
23
+ import assert from 'node:assert/strict';
24
+ import { createSink, createEmitter, RULES, EVENT_NAME } from './reference-emitter.mjs';
25
+
26
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
27
+ const fixture = (name) => JSON.parse(readFileSync(path.join(HERE, name), 'utf8'));
28
+
29
+ const PIXEL_BROWSER = 'purchase-pixel-browser.json';
30
+ const CAPI_SERVER = 'purchase-capi-server.json';
31
+ const CAPI_FORBIDDEN = 'purchase-capi-from-thank-you-page.json';
32
+
33
+ /** The purchase is read OUT of the fixture, so the values have one home. */
34
+ function purchaseFrom(name, cleared) {
35
+ const event = fixture(name).data[0];
36
+ return {
37
+ conversionEventId: event.event_id,
38
+ value: event.custom_data.value,
39
+ currency: event.custom_data.currency,
40
+ contentIds: event.custom_data.content_ids,
41
+ numItems: event.custom_data.num_items,
42
+ externalId: event.user_data.external_id[0],
43
+ emailSha256: event.user_data.em[0],
44
+ // The raw address a wrong emitter would send instead. sha256 of this string is the
45
+ // `em` above, so the two fixtures are consistent and neither is a real person.
46
+ email: 'placeholder@example.invalid',
47
+ clientIpAddress: event.user_data.client_ip_address,
48
+ clientUserAgent: event.user_data.client_user_agent,
49
+ fbp: event.user_data.fbp,
50
+ fbc: event.user_data.fbc,
51
+ eventTime: event.event_time,
52
+ eventSourceUrl: event.event_source_url,
53
+ cleared,
54
+ };
55
+ }
56
+
57
+ /** Carol paid by card: the charge cleared, and the webhook says so. */
58
+ const CLEARED = () => purchaseFrom(CAPI_SERVER, true);
59
+ /** Bob chose a bank debit: the session completed, the money has not moved and may not. */
60
+ const NOT_CLEARED = () => purchaseFrom(CAPI_FORBIDDEN, false);
61
+
62
+ function harness(without = []) {
63
+ const sink = createSink();
64
+ return { sink, emitter: createEmitter(sink, { without }) };
65
+ }
66
+
67
+ /** The real sequence: the browser fires on the thank-you page, the webhook lands when it lands. */
68
+ function runPurchase({ sink, emitter }, purchase) {
69
+ emitter.onThankYouPage(purchase);
70
+ if (purchase.cleared) emitter.onChargeCleared(purchase);
71
+ return sink;
72
+ }
73
+
74
+ export const INVARIANTS = [
75
+ {
76
+ id: 'pixel-and-capi-carry-one-event-id',
77
+ fixtures: [PIXEL_BROWSER, CAPI_SERVER],
78
+ states: 'one id, generated once and used by both sides, or the revenue counts twice',
79
+ breaks: ['shared-event-id'],
80
+ run({ sink, emitter }) {
81
+ runPurchase({ sink, emitter }, CLEARED());
82
+ // The browser side is read from the SHIPPED fixture rather than from the sink. That
83
+ // is deliberate: it keeps this assertion independent of whether the browser event
84
+ // fired at all, which is a different rule with its own fixture. Reading both sides
85
+ // out of one emitter is how the two got tangled on the first pass.
86
+ const pixelId = fixture(PIXEL_BROWSER)[3].eventID;
87
+ const serverId = sink.capi[0].data[0].event_id;
88
+ assert.equal(serverId, pixelId,
89
+ `the pixel carries ${pixelId} and the server sent ${serverId} — nothing will ever `
90
+ + 'deduplicate these two, and one purchase is two conversions');
91
+ },
92
+ },
93
+ {
94
+ id: 'pixel-and-capi-carry-one-event-name',
95
+ fixtures: [PIXEL_BROWSER, CAPI_SERVER],
96
+ states: 'Meta compares the name as well as the id, and the name is the half that is missed',
97
+ breaks: ['exact-event-name'],
98
+ run({ sink, emitter }) {
99
+ runPurchase({ sink, emitter }, CLEARED());
100
+ const pixelName = fixture(PIXEL_BROWSER)[1]; // the browser contract, not the sink
101
+ const serverName = sink.capi[0].data[0].event_name;
102
+ assert.equal(serverName, pixelName,
103
+ `'${pixelName}' in the browser and '${serverName}' on the server: a shared id `
104
+ + 'deduplicates nothing when the names differ');
105
+ assert.equal(serverName, EVENT_NAME);
106
+ },
107
+ },
108
+ {
109
+ id: 'no-purchase-reported-before-the-charge-cleared',
110
+ fixtures: [CAPI_FORBIDDEN],
111
+ states: 'the thank-you page has no idea whether the money moved, so it reports nothing',
112
+ breaks: ['webhook-sourced'],
113
+ run({ sink, emitter }) {
114
+ runPurchase({ sink, emitter }, NOT_CLEARED());
115
+ assert.equal(sink.capi.length, 0,
116
+ 'a Conversions API purchase was sent for a charge that has not cleared — this is '
117
+ + 'the event that exists for every session that reached the page and for no refund');
118
+ assert.equal(sink.pixel.length, 0,
119
+ 'a Purchase pixel fired for a session whose payment is still pending');
120
+ assert.notDeepEqual(sink.capi[0], fixture(CAPI_FORBIDDEN));
121
+ },
122
+ },
123
+ {
124
+ id: 'the-browser-event-is-kept',
125
+ fixtures: [PIXEL_BROWSER],
126
+ states: 'the browser event stays and stays subordinate — it carries the click ids',
127
+ breaks: ['keep-browser-event'],
128
+ run({ sink, emitter }) {
129
+ runPurchase({ sink, emitter }, CLEARED());
130
+ assert.equal(sink.pixel.length, 1,
131
+ 'no browser event fired: the click id, the consent state and the session are lost, '
132
+ + 'and only the browser had them');
133
+ assert.equal(sink.pixel[0][0], 'track');
134
+ },
135
+ },
136
+ {
137
+ id: 'identifiers-reach-the-server-hashed',
138
+ fixtures: [CAPI_SERVER],
139
+ states: 'advanced matching sends hashes, never the address itself',
140
+ breaks: ['hashed-identifiers'],
141
+ run({ sink, emitter }) {
142
+ runPurchase({ sink, emitter }, CLEARED());
143
+ const userData = sink.capi[0].data[0].user_data;
144
+ for (const value of userData.em) {
145
+ assert.match(value, /^[0-9a-f]{64}$/,
146
+ `user_data.em carried ${JSON.stringify(value)} — a raw identifier, not a sha256`);
147
+ }
148
+ assert.ok(!JSON.stringify(userData).includes('@'),
149
+ 'user_data carries something with an @ in it');
150
+ },
151
+ },
152
+ {
153
+ id: 'the-shipped-capi-fixture-is-what-a-correct-emitter-sends',
154
+ fixtures: [CAPI_SERVER, PIXEL_BROWSER],
155
+ states: 'the fixtures are the reference output, byte for byte — not an illustration',
156
+ // Broad by construction: it compares the whole body, so every rule that changes any
157
+ // field turns it red. It is here so a reader can trust the fixtures they copy.
158
+ breaks: ['shared-event-id', 'exact-event-name', 'hashed-identifiers', 'keep-browser-event'],
159
+ run({ sink, emitter }) {
160
+ runPurchase({ sink, emitter }, CLEARED());
161
+ assert.deepEqual(sink.capi[0], fixture(CAPI_SERVER));
162
+ assert.deepEqual(sink.pixel[0], fixture(PIXEL_BROWSER));
163
+ },
164
+ },
165
+ ];
166
+
167
+ // ------------------------------------------------------------------------- runners
168
+
169
+ async function runOne(invariant, without) {
170
+ const context = harness(without);
171
+ try {
172
+ await invariant.run(context);
173
+ return null;
174
+ } catch (error) {
175
+ return error.message.split('\n')[0];
176
+ }
177
+ }
178
+
179
+ async function runAll() {
180
+ let failed = 0;
181
+ for (const invariant of INVARIANTS) {
182
+ const why = await runOne(invariant, []);
183
+ if (why) {
184
+ failed += 1;
185
+ console.error(`FAIL ${invariant.id}\n ${why}`);
186
+ } else {
187
+ console.log(`pass ${invariant.id} — ${invariant.states}`);
188
+ }
189
+ }
190
+ if (failed) {
191
+ console.error(`\n${failed} of ${INVARIANTS.length} deduplication assertions failed`);
192
+ return 1;
193
+ }
194
+ console.log(`\nOK: ${INVARIANTS.length} deduplication assertions hold against the emitter`);
195
+ return 0;
196
+ }
197
+
198
+ async function selfTest() {
199
+ const problems = [];
200
+ const matrix = [];
201
+ for (const invariant of INVARIANTS) {
202
+ const broke = [];
203
+ for (const rule of RULES) {
204
+ if (await runOne(invariant, [rule])) broke.push(rule);
205
+ }
206
+ matrix.push({ id: invariant.id, broke });
207
+ const expected = [...invariant.breaks].sort().join(', ');
208
+ const measured = [...broke].sort().join(', ');
209
+ if (expected !== measured) {
210
+ problems.push(`${invariant.id}: declares breaks [${expected || '-'}], measured [${measured || '-'}]`);
211
+ }
212
+ if (broke.length === 0) {
213
+ problems.push(`${invariant.id}: no mutant breaks it — the assertion cannot fail`);
214
+ }
215
+ }
216
+
217
+ const width = Math.max(...matrix.map((row) => row.id.length));
218
+ console.log(`\nassertion x mutant — ${RULES.length} mutants, `
219
+ + 'and which removed rule turns each assertion red\n');
220
+ for (const row of matrix) {
221
+ console.log(` ${row.id.padEnd(width)} ${row.broke.join(', ') || '(nothing)'}`);
222
+ }
223
+
224
+ console.log('\nisolation — the fixture that separates each rule from every other\n');
225
+ for (const rule of RULES) {
226
+ const isolating = matrix.filter((row) => row.broke.length === 1 && row.broke[0] === rule);
227
+ if (isolating.length) {
228
+ console.log(` ${rule.padEnd(20)} ${isolating.map((r) => r.id).join(', ')}`);
229
+ } else {
230
+ problems.push(`no fixture isolates ${rule} — every assertion it breaks is also broken `
231
+ + 'by another rule, so neither is proven');
232
+ }
233
+ }
234
+
235
+ if (problems.length) {
236
+ console.error(`\nFAIL: ${problems.length} problem(s) with the assertion pack itself`);
237
+ for (const p of problems) console.error(` - ${p}`);
238
+ return 1;
239
+ }
240
+ console.log(`\nOK: ${INVARIANTS.length} assertions, each watched failing; `
241
+ + `${RULES.length} rules, each isolated by a fixture`);
242
+ return 0;
243
+ }
244
+
245
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
246
+ if (isMain) {
247
+ const mode = process.argv.includes('--self-test') ? selfTest : runAll;
248
+ process.exit(await mode());
249
+ }
@@ -0,0 +1,75 @@
1
+ {
2
+ "skill": "ad-tracking",
3
+ "assertionPack": "assert-dedup-contract.mjs",
4
+ "referenceHandler": "reference-emitter.mjs",
5
+ "runbook": "README.md",
6
+ "note": "The single home of the invariant -> fixture -> document mapping. test/validate.py in this repository reads it: every fixture must be claimed by a row, every row's fixtures must exist, and every claiming document must still carry its phrase, name the invariant id, and name a path under fixtures/. A claim whose fixture is gone and a fixture nobody claims are both failures.",
7
+ "invariants": [
8
+ {
9
+ "id": "pixel-and-capi-carry-one-event-id",
10
+ "isolates": "shared-event-id",
11
+ "fixtures": ["purchase-pixel-browser.json", "purchase-capi-server.json"],
12
+ "claimedBy": [
13
+ {
14
+ "document": "SKILL.md",
15
+ "phrase": "carry the SAME `event_id` / `transaction_id`, or both are counted."
16
+ }
17
+ ]
18
+ },
19
+ {
20
+ "id": "pixel-and-capi-carry-one-event-name",
21
+ "isolates": "exact-event-name",
22
+ "fixtures": ["purchase-pixel-browser.json", "purchase-capi-server.json"],
23
+ "claimedBy": [
24
+ {
25
+ "document": "references/meta-linkedin.md",
26
+ "phrase": "**Two fields must match, not one.**"
27
+ }
28
+ ]
29
+ },
30
+ {
31
+ "id": "no-purchase-reported-before-the-charge-cleared",
32
+ "isolates": "webhook-sourced",
33
+ "fixtures": ["purchase-capi-from-thank-you-page.json"],
34
+ "claimedBy": [
35
+ {
36
+ "document": "SKILL.md",
37
+ "phrase": "**A purchase is not a browser event, and that decides where it comes from.**"
38
+ }
39
+ ]
40
+ },
41
+ {
42
+ "id": "the-browser-event-is-kept",
43
+ "isolates": "keep-browser-event",
44
+ "fixtures": ["purchase-pixel-browser.json"],
45
+ "claimedBy": [
46
+ {
47
+ "document": "SKILL.md",
48
+ "phrase": "**The browser event stays, and it stays subordinate.**"
49
+ }
50
+ ]
51
+ },
52
+ {
53
+ "id": "identifiers-reach-the-server-hashed",
54
+ "isolates": "hashed-identifiers",
55
+ "fixtures": ["purchase-capi-server.json"],
56
+ "claimedBy": [
57
+ {
58
+ "document": "references/meta-linkedin.md",
59
+ "phrase": "SHA-256 does **not** make a value safe to send"
60
+ }
61
+ ]
62
+ },
63
+ {
64
+ "id": "the-shipped-capi-fixture-is-what-a-correct-emitter-sends",
65
+ "isolates": null,
66
+ "fixtures": ["purchase-capi-server.json", "purchase-pixel-browser.json"],
67
+ "claimedBy": [
68
+ {
69
+ "document": "references/meta-linkedin.md",
70
+ "phrase": "the fixtures are the reference output rather than an illustration"
71
+ }
72
+ ]
73
+ }
74
+ ]
75
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "data": [
3
+ {
4
+ "event_name": "Purchase",
5
+ "event_time": 1767312000,
6
+ "event_id": "evtid_PLACEHOLDER_purchase_bob",
7
+ "event_source_url": "https://example.invalid/after",
8
+ "action_source": "website",
9
+ "user_data": {
10
+ "em": [
11
+ "34aeb9fb3be8b407bdd0beb712d571974bbddbb5bfc0e41cb666331ee5fcc47b"
12
+ ],
13
+ "external_id": [
14
+ "usr_PLACEHOLDER_bob"
15
+ ],
16
+ "client_ip_address": "203.0.113.10",
17
+ "client_user_agent": "Mozilla/5.0 (PLACEHOLDER user agent)",
18
+ "fbp": "fb.1.1767312000.9876543210",
19
+ "fbc": "fb.1.1767312000.PLACEHOLDERCLICKID"
20
+ },
21
+ "custom_data": {
22
+ "value": 29,
23
+ "currency": "USD",
24
+ "content_ids": [
25
+ "prod_PLACEHOLDER_pro"
26
+ ],
27
+ "content_type": "product",
28
+ "num_items": 1
29
+ }
30
+ }
31
+ ]
32
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "data": [
3
+ {
4
+ "event_name": "Purchase",
5
+ "event_time": 1767398400,
6
+ "event_id": "evtid_PLACEHOLDER_purchase_carol",
7
+ "event_source_url": "https://example.invalid/after",
8
+ "action_source": "website",
9
+ "user_data": {
10
+ "em": ["34aeb9fb3be8b407bdd0beb712d571974bbddbb5bfc0e41cb666331ee5fcc47b"],
11
+ "external_id": ["usr_PLACEHOLDER_carol"],
12
+ "client_ip_address": "203.0.113.10",
13
+ "client_user_agent": "Mozilla/5.0 (PLACEHOLDER user agent)",
14
+ "fbp": "fb.1.1767398400.1234567890",
15
+ "fbc": "fb.1.1767398400.PLACEHOLDERCLICKID"
16
+ },
17
+ "custom_data": {
18
+ "value": 29.0,
19
+ "currency": "USD",
20
+ "content_ids": ["prod_PLACEHOLDER_pro"],
21
+ "content_type": "product",
22
+ "num_items": 1
23
+ }
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,12 @@
1
+ [
2
+ "track",
3
+ "Purchase",
4
+ {
5
+ "value": 29.0,
6
+ "currency": "USD",
7
+ "content_ids": ["prod_PLACEHOLDER_pro"],
8
+ "content_type": "product",
9
+ "num_items": 1
10
+ },
11
+ { "eventID": "evtid_PLACEHOLDER_purchase_carol" }
12
+ ]
@@ -0,0 +1,129 @@
1
+ // A purchase emitter that satisfies the deduplication contract this skill states, and the
2
+ // same emitter with one rule removed at a time.
3
+ //
4
+ // Two call sites, and which one may report a purchase is the whole invariant:
5
+ //
6
+ // onChargeCleared(purchase) <- the webhook handler, the only place that knows the money
7
+ // moved. Sends the Conversions API event.
8
+ // onThankYouPage(purchase) <- the browser. Fires the pixel with the id the server will
9
+ // reuse, and reports nothing on its own authority.
10
+ //
11
+ // `without: ['shared-event-id']` and friends delete the lines a generated integration
12
+ // routinely omits; `fixtures/README.md` shows the code each flag removes. Nothing here
13
+ // reaches the network — the sink collects what WOULD have been sent, which is what the
14
+ // assertions read. No access token appears in this directory at all.
15
+
16
+ export const RULES = Object.freeze([
17
+ 'shared-event-id',
18
+ 'exact-event-name',
19
+ 'webhook-sourced',
20
+ 'keep-browser-event',
21
+ 'hashed-identifiers',
22
+ ]);
23
+
24
+ /** Meta compares `event_name` as well as the id, and it is the half that gets missed. */
25
+ export const EVENT_NAME = 'Purchase';
26
+
27
+ export function createSink() {
28
+ return { pixel: [], capi: [] };
29
+ }
30
+
31
+ function customData(purchase) {
32
+ return {
33
+ value: purchase.value,
34
+ currency: purchase.currency,
35
+ content_ids: purchase.contentIds,
36
+ content_type: 'product',
37
+ num_items: purchase.numItems,
38
+ };
39
+ }
40
+
41
+ export function createEmitter(sink, options = {}) {
42
+ const without = new Set(options.without || []);
43
+ for (const rule of without) {
44
+ if (!RULES.includes(rule)) throw new Error(`unknown rule: ${rule}`);
45
+ }
46
+ const has = (rule) => !without.has(rule);
47
+ let generated = 0;
48
+
49
+ /** One id, generated once at session creation, carried by both sides. */
50
+ function eventIdFor(purchase) {
51
+ if (has('shared-event-id')) return purchase.conversionEventId;
52
+ generated += 1;
53
+ return `evtid_generated_at_emission_${generated}`;
54
+ }
55
+
56
+ function eventNameFor(side) {
57
+ if (has('exact-event-name')) return EVENT_NAME;
58
+ // The trap: an id that matches and a name that does not deduplicates nothing.
59
+ return side === 'server' ? EVENT_NAME.toLowerCase() : EVENT_NAME;
60
+ }
61
+
62
+ function userData(purchase) {
63
+ const identifiers = has('hashed-identifiers')
64
+ ? { em: [purchase.emailSha256], external_id: [purchase.externalId] }
65
+ : { em: [purchase.email], external_id: [purchase.externalId] };
66
+ return {
67
+ ...identifiers,
68
+ client_ip_address: purchase.clientIpAddress,
69
+ client_user_agent: purchase.clientUserAgent,
70
+ fbp: purchase.fbp,
71
+ fbc: purchase.fbc,
72
+ };
73
+ }
74
+
75
+ function capiBody(purchase) {
76
+ return {
77
+ data: [{
78
+ event_name: eventNameFor('server'),
79
+ event_time: purchase.eventTime,
80
+ event_id: eventIdFor(purchase),
81
+ event_source_url: purchase.eventSourceUrl,
82
+ action_source: 'website',
83
+ user_data: userData(purchase),
84
+ custom_data: customData(purchase),
85
+ }],
86
+ };
87
+ }
88
+
89
+ return {
90
+ /** The webhook handler. `cleared` is the provider's word, not the browser's. */
91
+ onChargeCleared(purchase) {
92
+ if (purchase.cleared !== true) {
93
+ // Reached only by a caller that guessed. The gate below is the one that matters.
94
+ return { sent: false, why: 'the charge has not cleared' };
95
+ }
96
+ sink.capi.push(capiBody(purchase));
97
+ return { sent: true };
98
+ },
99
+
100
+ /**
101
+ * The thank-you page. It fires the pixel and NOTHING ELSE: a browser cannot know
102
+ * whether the charge cleared, and for an async method it usually has not.
103
+ */
104
+ onThankYouPage(purchase) {
105
+ if (!has('webhook-sourced')) {
106
+ // The defect: reporting the purchase from the one place with no idea whether the
107
+ // money moved. `purchase-capi-from-thank-you-page.json` is what this sends.
108
+ sink.capi.push(capiBody(purchase));
109
+ }
110
+ if (has('webhook-sourced') && purchase.cleared !== true) {
111
+ // Not even the pixel: a Purchase fired here exists for every session that reached
112
+ // the page, including the ones that never paid.
113
+ return { fired: false, why: 'the charge has not cleared' };
114
+ }
115
+ if (!has('keep-browser-event')) {
116
+ // The other direction: dropping the browser event loses the click ids, the consent
117
+ // state and the session that only the browser carries.
118
+ return { fired: false, why: 'browser event removed' };
119
+ }
120
+ sink.pixel.push([
121
+ 'track',
122
+ eventNameFor('browser'),
123
+ customData(purchase),
124
+ { eventID: eventIdFor(purchase) },
125
+ ]);
126
+ return { fired: true };
127
+ },
128
+ };
129
+ }
@@ -100,7 +100,10 @@ export function setFbAdvancedMatching(data: { em?: string; fn?: string }) {
100
100
  Advanced Matching is the one place a tracking integration can breach Meta's terms
101
101
  by accident, because the wrapper accepts whatever you hand it and hashes it
102
102
  without judgement. SHA-256 does **not** make a value safe to send: hashing is a
103
- matching mechanism, not a permission.
103
+ matching mechanism, not a permission. That the wrapper receives a hash at all is checkable:
104
+ `identifiers-reach-the-server-hashed` in `fixtures/assert-dedup-contract.mjs` requires every
105
+ `user_data.em` entry to be 64 hex characters and refuses anything with an `@` in it. What may
106
+ be hashed in the first place is the table below, and no assertion decides it.
104
107
 
105
108
  Meta's Business Tools Terms prohibit sharing data that "includes or is based on"
106
109
  any of these, and the prohibition covers the Pixel, the Conversions API, the
@@ -184,6 +187,15 @@ the server event usually arrives first, and this method then does nothing. Use
184
187
  Neither method deduplicates within a single source: two pixel fires of the same
185
188
  purchase are two conversions no matter what.
186
189
 
190
+ **Proved, not asserted.** `fixtures/purchase-pixel-browser.json` and
191
+ `fixtures/purchase-capi-server.json` are the two payloads one purchase produces, and
192
+ `fixtures/assert-dedup-contract.mjs` compares them: `pixel-and-capi-carry-one-event-name`
193
+ fails an emitter that sends `Purchase` from the browser and a lowercased variant from the
194
+ server, which is the half of this contract that gets missed. The shape itself is a ratchet —
195
+ `the-shipped-capi-fixture-is-what-a-correct-emitter-sends` requires the emitted body to equal
196
+ the fixture byte for byte, so **the fixtures are the reference output rather than an
197
+ illustration**.
198
+
187
199
  Source: [Deduplicate pixel and Conversions API
188
200
  events](https://developers.facebook.com/docs/marketing-api/conversions-api/deduplicate-pixel-and-server-events),
189
201
  read 2026-08-16.