@ssheleg/sheleg-dev 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +347 -0
- package/README.md +101 -1
- package/SECURITY.md +190 -52
- package/bin/sheleg-dev.js +18 -0
- package/package.json +2 -2
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/sheleg-dev/hooks/hooks.json +29 -0
- package/plugins/sheleg-dev/hooks/lib/moneygate.js +587 -0
- package/plugins/sheleg-dev/hooks/money-gate.js +68 -0
- package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +24 -75
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +76 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +249 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/manifest.json +75 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-from-thank-you-page.json +32 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-server.json +26 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-pixel-browser.json +12 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/reference-emitter.mjs +129 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/event-tracking.md +42 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +159 -2
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +68 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
- package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +31 -13
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +48 -104
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +110 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +404 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-partial.json +52 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-remainder.json +63 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-async-payment-failed.json +43 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-async-unpaid.json +43 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-paid.json +42 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-next-period.json +70 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-redelivery.json +70 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle.json +61 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-update-proration.json +90 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +172 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +315 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +27 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +38 -2
- package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +43 -1
|
@@ -220,19 +220,11 @@ fbq('track', 'PageView');
|
|
|
220
220
|
|
|
221
221
|
### Standard Events
|
|
222
222
|
|
|
223
|
-
Meta defines 17 standard events. The
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
| `CompleteRegistration` | User signs up | `content_name` (method), `status: true` | Separate from GA4 `sign_up` |
|
|
229
|
-
| `InitiateCheckout` | Checkout started | `value`, `currency`, `content_name`, `content_category`, `num_items` | |
|
|
230
|
-
| `Purchase` | Purchase completed | `value` (required), `currency` (required), `content_name`, `content_category`, `content_type`, `contents[]` | `value` + `currency` are **mandatory** for optimisation |
|
|
231
|
-
| `Subscribe` | Recurring subscription | `value`, `currency`, `predicted_ltv` | Distinct from one-time Purchase |
|
|
232
|
-
| `Lead` | Lead form submitted | (optional params) | |
|
|
233
|
-
| `ViewContent` | Key content viewed | `content_name`, `content_category` | |
|
|
234
|
-
| `AddToCart` | Item added to cart | `content_name`, `value`, `currency` | |
|
|
235
|
-
| `Search` | Search performed | `search_string` | |
|
|
223
|
+
Meta defines 17 standard events. The four that carry a SaaS funnel are
|
|
224
|
+
`CompleteRegistration`, `InitiateCheckout`, `Purchase` and `Subscribe`, and only
|
|
225
|
+
`Purchase` has mandatory parameters: `value` and `currency`, without which Meta
|
|
226
|
+
cannot optimise. The full table — every event, its parameters and the traps —
|
|
227
|
+
is in `references/meta-linkedin.md` → **Standard events**.
|
|
236
228
|
|
|
237
229
|
### Deeper Meta and LinkedIn detail
|
|
238
230
|
|
|
@@ -241,68 +233,21 @@ firing wrapper, advanced matching (hashed identifiers, and what must never be
|
|
|
241
233
|
sent) and CAPI deduplication.
|
|
242
234
|
## LinkedIn Insight Tag
|
|
243
235
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
```javascript
|
|
251
|
-
_linkedin_partner_id = "PARTNER_ID";
|
|
252
|
-
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
|
|
253
|
-
window._linkedin_data_partner_ids.push(_linkedin_partner_id);
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
### Conversion Tracking
|
|
257
|
-
|
|
258
|
-
LinkedIn conversions are configured in Campaign Manager (not in code). The Insight Tag automatically tracks page views, and you define URL-based conversion rules in the LinkedIn Campaign Manager:
|
|
259
|
-
|
|
260
|
-
1. Campaign Manager → Analyze → Conversion tracking → Create conversion
|
|
261
|
-
2. Define URL rules (e.g. `/thank-you`, `/onboarding`)
|
|
262
|
-
3. LinkedIn Insight Tag automatically matches page loads to conversion rules
|
|
263
|
-
|
|
264
|
-
For event-based conversions, use `window.lintrk('track', { conversion_id: 123456 })`.
|
|
265
|
-
|
|
266
|
-
**Docs:**
|
|
267
|
-
- [LinkedIn Insight Tag setup](https://www.linkedin.com/help/lms/answer/a418880)
|
|
268
|
-
- [LinkedIn conversion tracking](https://www.linkedin.com/help/lms/answer/a423304)
|
|
269
|
-
|
|
270
|
-
---
|
|
236
|
+
Consent-gated on the same wrapper as the Meta Pixel, env var
|
|
237
|
+
`NEXT_PUBLIC_LINKEDIN_PARTNER_ID`. Conversions are configured in Campaign Manager
|
|
238
|
+
rather than in code, so there is nothing to deduplicate and nothing that fails
|
|
239
|
+
silently in the bundle — but a URL rule on a refreshable page counts every
|
|
240
|
+
refresh. Setup snippet, the Campaign Manager steps and `lintrk` are in
|
|
241
|
+
`references/meta-linkedin.md` → **LinkedIn conversion tracking**.
|
|
271
242
|
|
|
272
243
|
## User Identification
|
|
273
244
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
| Meta Pixel | Advanced Matching via `fbq('init', PIXEL_ID, { em, fn })` | After login/onboarding | Better conversion attribution, larger custom audiences |
|
|
281
|
-
| Mixpanel | `alias()` + `identify()` | After login/onboarding | Merge anonymous → identified user profiles |
|
|
282
|
-
|
|
283
|
-
**All identification calls should be placed in a single identification component** that runs in the dashboard/authenticated layout. This ensures:
|
|
284
|
-
- User data is sent once per session
|
|
285
|
-
- All platforms receive identification data at the same time
|
|
286
|
-
- Pre-login anonymous events merge into the identified profile
|
|
287
|
-
|
|
288
|
-
### Alias vs Identify (Mixpanel)
|
|
289
|
-
|
|
290
|
-
- `alias(userId, anonymousId)` — creates a permanent link between the anonymous and real user ID. Must be called **exactly once** per user, **before** `identify()`. Use a localStorage flag to prevent duplicate calls.
|
|
291
|
-
- `identify(userId)` — sets the user ID for all subsequent events.
|
|
292
|
-
|
|
293
|
-
### Timing
|
|
294
|
-
|
|
295
|
-
Fire identification in a `useEffect` in the authenticated layout:
|
|
296
|
-
|
|
297
|
-
```typescript
|
|
298
|
-
useEffect(() => {
|
|
299
|
-
// 1. Mixpanel alias + identify
|
|
300
|
-
// 2. GA4 user_id + Enhanced Conversions
|
|
301
|
-
// 3. Meta Advanced Matching
|
|
302
|
-
}, [userId, email, name]);
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
---
|
|
245
|
+
One rule the body keeps, because getting it wrong corrupts attribution rather
|
|
246
|
+
than losing it: **identify before you track, never after**. An event fired for an
|
|
247
|
+
anonymous id and re-attributed later is two users to every platform that already
|
|
248
|
+
ingested it. The per-platform strategy, the Mixpanel `alias` vs `identify`
|
|
249
|
+
distinction and the timing rules are in `references/event-tracking.md` →
|
|
250
|
+
**User identification**.
|
|
306
251
|
|
|
307
252
|
## Event naming
|
|
308
253
|
|
|
@@ -316,9 +261,13 @@ schema and the full purchase / add_to_cart / begin_checkout set.
|
|
|
316
261
|
|
|
317
262
|
**Deduplication is the part that silently doubles revenue.** A purchase fired
|
|
318
263
|
from both the browser and the server (Conversions API, server-side GA4) must
|
|
319
|
-
carry the SAME `event_id` / `transaction_id`, or both are counted.
|
|
320
|
-
reloading the thank-you page:
|
|
321
|
-
|
|
264
|
+
carry the SAME `event_id` / `transaction_id`, or both are counted. Run
|
|
265
|
+
`fixtures/assert-dedup-contract.mjs` rather than reloading the thank-you page:
|
|
266
|
+
`pixel-and-capi-carry-one-event-id` and `pixel-and-capi-carry-one-event-name` fail an
|
|
267
|
+
emitter that shares one field and not the other,
|
|
268
|
+
`no-purchase-reported-before-the-charge-cleared` fails one that reports from the redirect,
|
|
269
|
+
and `the-browser-event-is-kept` fails one that drops the pixel. `--self-test` deletes each
|
|
270
|
+
rule and watches the matching assertion go red.
|
|
322
271
|
|
|
323
272
|
**A purchase is not a browser event, and that decides where it comes from.**
|
|
324
273
|
Every other event on the list is something a person did in a tab, so the browser
|
|
@@ -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
|
+
}
|