@ssheleg/sheleg-dev 0.6.0 → 0.8.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 (38) hide show
  1. package/CHANGELOG.md +318 -0
  2. package/README.md +115 -1
  3. package/SECURITY.md +199 -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 +77 -0
  12. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +397 -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 +17 -1
  19. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +82 -77
  20. package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +55 -0
  21. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
  22. package/plugins/sheleg-dev/skills/crypto-payments/references/testing-and-local-dev.md +60 -0
  23. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +1 -1
  24. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +111 -0
  25. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +564 -0
  26. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-partial.json +52 -0
  27. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-remainder.json +63 -0
  28. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-async-payment-failed.json +43 -0
  29. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-async-unpaid.json +43 -0
  30. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-paid.json +42 -0
  31. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-next-period.json +70 -0
  32. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-redelivery.json +70 -0
  33. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle.json +61 -0
  34. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-update-proration.json +90 -0
  35. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +172 -0
  36. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +315 -0
  37. package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +39 -2
  38. package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +43 -1
@@ -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,14 @@ 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. **Both
106
+ halves are separately watched failing**, which they were not until 2026-08-20: the pack's
107
+ `--self-test` measured one row per invariant, so the `@` guard could be neutered while the
108
+ hex-shape assertion above it kept the invariant red, and this paragraph advertised a control
109
+ nothing had ever seen fail. The matrix is now per call site. What may be hashed in the first
110
+ place is the table below, and no assertion decides it.
104
111
 
105
112
  Meta's Business Tools Terms prohibit sharing data that "includes or is based on"
106
113
  any of these, and the prohibition covers the Pixel, the Conversions API, the
@@ -184,6 +191,15 @@ the server event usually arrives first, and this method then does nothing. Use
184
191
  Neither method deduplicates within a single source: two pixel fires of the same
185
192
  purchase are two conversions no matter what.
186
193
 
194
+ **Proved, not asserted.** `fixtures/purchase-pixel-browser.json` and
195
+ `fixtures/purchase-capi-server.json` are the two payloads one purchase produces, and
196
+ `fixtures/assert-dedup-contract.mjs` compares them: `pixel-and-capi-carry-one-event-name`
197
+ fails an emitter that sends `Purchase` from the browser and a lowercased variant from the
198
+ server, which is the half of this contract that gets missed. The shape itself is a ratchet —
199
+ `the-shipped-capi-fixture-is-what-a-correct-emitter-sends` requires the emitted body to equal
200
+ the fixture byte for byte, so **the fixtures are the reference output rather than an
201
+ illustration**.
202
+
187
203
  Source: [Deduplicate pixel and Conversions API
188
204
  events](https://developers.facebook.com/docs/marketing-api/conversions-api/deduplicate-pixel-and-server-events),
189
205
  read 2026-08-16.
@@ -39,14 +39,13 @@ and anything else that issues an invoice and calls you back.
39
39
  - [Status mapping — and finality](#status-mapping--and-finality)
40
40
  - [Webhook signature verification](#webhook-signature-verification)
41
41
  - [Idempotent webhook processing](#idempotent-webhook-processing)
42
- - [IP allowlisting behind a proxy](#ip-allowlisting-behind-a-proxy)
43
- - [CSRF exemption for callback routes](#csrf-exemption-for-callback-routes)
42
+ - [Callback route hardening](#callback-route-hardening)
44
43
  - [The conversion buffer](#the-conversion-buffer)
45
44
  - [Reconciliation fields](#reconciliation-fields)
46
45
  - [Crediting: the amount waterfall](#crediting-the-amount-waterfall)
47
46
  - [Refunds and AML holds](#refunds-and-aml-holds)
48
- - [Local development](#local-development)
49
- - [Test matrix](#test-matrix)
47
+ - [The test/live credential boundary](#the-testlive-credential-boundary)
48
+ - [Local development and the test matrix](#local-development-and-the-test-matrix)
50
49
  - [Security checklist](#security-checklist)
51
50
  - [Common pitfalls](#common-pitfalls)
52
51
 
@@ -191,46 +190,17 @@ the read.
191
190
 
192
191
  ---
193
192
 
194
- ## IP allowlisting behind a proxy
193
+ ## Callback route hardening
195
194
 
196
- Signature verification is the real gate; the allowlist is defence in depth and
197
- cheap. It is also the single most common cause of "webhooks stopped working
198
- after we moved to a load balancer".
195
+ Two things about the *request* rather than about the money in it, and each one is the
196
+ usual cause of "webhooks stopped working after we moved to a load balancer": reading the
197
+ caller IP from the proxy hop you actually own instead of `x-forwarded-for[0]`, and
198
+ exempting the **one** callback path from CSRF by exact match rather than by prefix — a
199
+ prefix on `/api/payments` exempts the checkout endpoint too, which is where the money is.
199
200
 
200
- ```ts
201
- function callerIp(req): string | null {
202
- // Trust ONLY the hop your own infrastructure appends.
203
- const xff = req.headers['x-forwarded-for'];
204
- if (typeof xff === 'string') {
205
- const hops = xff.split(',').map((s) => s.trim());
206
- return hops[hops.length - TRUSTED_PROXY_COUNT] ?? null;
207
- }
208
- return req.socket.remoteAddress ?? null;
209
- }
210
- ```
211
-
212
- Taking `xff[0]` trusts a header the client controls — anyone can claim to be the
213
- gateway. Count from the right by the number of proxies you actually run, and
214
- keep that number in configuration, because it changes when infrastructure does.
215
-
216
- Allowlist the provider's published callback IPs, log a rejection with the
217
- observed IP, and **never** fall back to "allow if the header is missing".
218
-
219
- ---
220
-
221
- ## CSRF exemption for callback routes
222
-
223
- A gateway cannot present your CSRF token. Exempt the callback path explicitly
224
- and narrowly:
225
-
226
- ```ts
227
- // middleware.ts (Next.js)
228
- export const config = { matcher: ['/((?!api/payments/webhook).*)'] };
229
- ```
230
-
231
- Exempt the **one** path, by exact match, and make it the only route in your app
232
- that skips CSRF. A prefix match on `/api/payments` exempts the checkout endpoint
233
- too, which is where the money is.
201
+ Both, with the code:
202
+ [`references/callback-route-hardening.md`](references/callback-route-hardening.md).
203
+ Signature verification above is the real gate; these are defence in depth.
234
204
 
235
205
  ---
236
206
 
@@ -309,47 +279,77 @@ Crypto has no chargeback, which people mistake for "no reversals".
309
279
  - **Never auto-refund from the webhook.** Route holds and refunds to a queue a
310
280
  human can see.
311
281
 
312
- ---
313
-
314
- ## Local development
315
-
316
- Two paths, and you want both:
317
-
318
- **Skip the gateway.** A `SKIP_BILLING=true` branch that credits immediately and
319
- logs loudly. Everyone building a feature that merely *touches* checkout should
320
- use this. Make it impossible in production: assert `NODE_ENV !== 'production'`
321
- at the branch, not just in config.
282
+ **And make it a precondition, not a paragraph.** The three lines above were advice for
283
+ four releases: an agent that never read them, and a shell nobody read, were unaffected by
284
+ all three. This pack now ships a `PreToolUse` gate —
285
+ `plugins/sheleg-dev/hooks/money-gate.js` — that refuses a refund, a payout, closing a
286
+ dispute, an export of `HELEKET_API_KEY` in a run declaring `test`, a live `sk_live_…` key
287
+ and `SKIP_BILLING=true` in production, until the authorised person has signed that
288
+ category off for the session. The pack's `README.md` carries the categories and how one is
289
+ authorised. In your application the same rule is a queue and a human; in the agent's
290
+ shell it is the hook.
322
291
 
323
- **Real gateway, tunnelled callback.** Callbacks cannot reach `localhost`:
324
-
325
- ```bash
326
- cloudflared tunnel --url http://localhost:3000
327
- # → https://random-name.trycloudflare.com → set as the callback base URL, restart the app
328
- ```
292
+ ---
329
293
 
330
- Then keep a **signed mock callback** in your test tooling — a script that builds
331
- a real payload, signs it with the dev key and POSTs it. This is what makes the
332
- sad paths (`wrong_amount`, `refund_fail`, `locked`) testable at all; you cannot
333
- ask a gateway to underpay you on demand.
294
+ ## The test/live credential boundary
295
+
296
+ **Establish what the provider actually offers before designing around it.** Card
297
+ processors hand you two parallel worlds — Stripe gives a whole second account and stamps
298
+ the environment into the key itself (`sk_test_` / `sk_live_`). Most crypto gateways do
299
+ not. Answer these three, from the provider's documentation, and write the answers down:
300
+
301
+ 1. **Is there a separate test credential?** Not "a test mode" — a *different secret*.
302
+ 2. **Can the environment be read from the key?** A prefix, a scope, anything.
303
+ 3. **Does the key also sign webhooks?** If yes, it cannot be scoped down: everything that
304
+ verifies a callback can also create a charge.
305
+
306
+ The answers decide the design, and the worst combination is common: one key, no marker,
307
+ "test mode" as a **toggle on the merchant account**. Then the same secret creates play
308
+ invoices before somebody moves a dashboard switch and real ones after — with no deploy and
309
+ no signal to any machine holding it.
310
+
311
+ **Build the strongest control the provider allows, in this order:**
312
+
313
+ 1. **A separate sandbox account**, so the credential on a dev machine *cannot* reach
314
+ production. This is the only control that still works after everyone has forgotten why
315
+ it is there — which is the whole argument for preferring it to a warning in a README.
316
+ 2. **A declared environment plus a boot assertion**, when there is no second credential.
317
+ Keep the declaration in a variable **separate from the secret** (`<PROVIDER>_ENV=test|
318
+ production`): one variable that both carries the key and names its environment cannot
319
+ be checked against itself, two can. Pin the production identity with something
320
+ non-secret — the merchant id, or a truncated hash of the live key — and refuse
321
+ **both** mismatches at **startup**:
322
+
323
+ - a live credential declared test — the obvious loss;
324
+ - a test credential declared live — the quiet one, where invoices settle to a merchant
325
+ nobody reconciles and nothing errors until revenue is missing.
326
+
327
+ Assert at module load, not in the payment handler: a run that merely *holds* the key
328
+ must fail too, and it is agent and CI runs that hold keys without ever charging
329
+ anything. And when the check cannot tell — nothing pinned — refuse on the test side.
330
+ "Could not prove it was safe" must never read as "it was safe".
331
+ 3. **Write down what is left.** Where the provider offers no test credential, a developer
332
+ holds a production one and the assertion narrows that window without closing it. An
333
+ unavoidable exposure that is named is a different object from one that is silent: the
334
+ first gets rotated, monitored and revisited, the second gets rediscovered by an
335
+ incident.
336
+
337
+ The same rule applies to the mock path below: `SKIP_BILLING=true` in production is not a
338
+ shortcut, it is a free-money path, and the assertion is what refuses it.
339
+
340
+ Worked end to end for one gateway with no test credential at all —
341
+ [`references/heleket-provider.md`](references/heleket-provider.md), *The test/live
342
+ boundary* and *Residual exposure*.
334
343
 
335
344
  ---
336
345
 
337
- ## Test matrix
346
+ ## Local development and the test matrix
338
347
 
339
- Cover these, and plant a defect against each before believing the green:
340
-
341
- | Case | What it catches |
342
- |---|---|
343
- | valid signature | the happy path |
344
- | tampered signature | constant-time compare, 403 not 500 |
345
- | signature over a payload containing a URL | the escaping trap |
346
- | duplicate delivery of a settling status | idempotency guard |
347
- | `paid_over` | crediting the received amount, not the invoiced |
348
- | `wrong_amount` | no full credit on a partial payment |
349
- | out-of-order delivery (final, then earlier) | the `notIn` guard, not timestamps |
350
- | callback from a non-allowlisted IP | proxy-aware extraction |
351
- | `refund_process` then `refund_fail` | refund is a state machine |
352
- | `locked` | no credit, no auto-cancel |
348
+ Two development paths you want both of — a `SKIP_BILLING=true` branch that is impossible
349
+ in production, and a real gateway behind a tunnel with a signed mock callback for the sad
350
+ paths a provider will not produce on demand — plus the ten cases to cover before believing
351
+ a green suite, each with the defect it catches:
352
+ [`references/testing-and-local-dev.md`](references/testing-and-local-dev.md).
353
353
 
354
354
  ---
355
355
 
@@ -363,6 +363,9 @@ Cover these, and plant a defect against each before believing the green:
363
363
  - [ ] CSRF exempted for exactly one path, by exact match
364
364
  - [ ] Entitlement never granted from a client-side redirect
365
365
  - [ ] API key and merchant id from environment, never in the repository
366
+ - [ ] A sandbox account is used where the provider offers one, or the reason it cannot be is written down
367
+ - [ ] The declared environment is a variable separate from the secret, and a boot assertion refuses **both** mismatches
368
+ - [ ] The assertion runs at module load, so a run that only holds the key fails too
366
369
  - [ ] Callback URL is HTTPS in every environment that is not a local tunnel
367
370
  - [ ] `SKIP_BILLING` asserted impossible in production
368
371
  - [ ] Reconciliation fields written at settlement time
@@ -382,3 +385,5 @@ Cover these, and plant a defect against each before believing the green:
382
385
  | "Paid" users with no product | credited from the browser redirect |
383
386
  | Half the payments land as `wrong_amount` | no conversion buffer |
384
387
  | Cannot answer "how much did we net" | commission and `merchantAmount` never stored |
388
+ | A dev machine, a CI job or an agent run is holding the production key | no separate credential, and no assertion binding the key to a declared environment |
389
+ | Real invoices in a merchant dashboard nobody watches | a staging credential declared live — the mismatch nothing refused |
@@ -0,0 +1,55 @@
1
+ # Callback route hardening — the proxy hop and the CSRF hole
2
+
3
+ Split out of `SKILL.md` on 2026-08-20, when its body measured ~4894 tokens against the
4
+ house working limit of 4750. Both sections below are web plumbing rather than payment
5
+ logic — they are about the request that carries a callback, not about the money in it —
6
+ which is why they are the seam. Signature verification (`SKILL.md`, *Webhook signature
7
+ verification*) is the real gate; these two are defence in depth, and each has a failure
8
+ mode that looks like "webhooks stopped working" rather than like a security event.
9
+
10
+ ---
11
+
12
+ ## IP allowlisting behind a proxy
13
+
14
+ Signature verification is the real gate; the allowlist is defence in depth and
15
+ cheap. It is also the single most common cause of "webhooks stopped working
16
+ after we moved to a load balancer".
17
+
18
+ ```ts
19
+ function callerIp(req): string | null {
20
+ // Trust ONLY the hop your own infrastructure appends.
21
+ const xff = req.headers['x-forwarded-for'];
22
+ if (typeof xff === 'string') {
23
+ const hops = xff.split(',').map((s) => s.trim());
24
+ return hops[hops.length - TRUSTED_PROXY_COUNT] ?? null;
25
+ }
26
+ return req.socket.remoteAddress ?? null;
27
+ }
28
+ ```
29
+
30
+ Taking `xff[0]` trusts a header the client controls — anyone can claim to be the
31
+ gateway. Count from the right by the number of proxies you actually run, and
32
+ keep that number in configuration, because it changes when infrastructure does.
33
+
34
+ Allowlist the provider's published callback IPs, log a rejection with the
35
+ observed IP, and **never** fall back to "allow if the header is missing".
36
+
37
+ ## CSRF exemption for callback routes
38
+
39
+ A gateway cannot present your CSRF token. Exempt the callback path explicitly
40
+ and narrowly:
41
+
42
+ ```ts
43
+ // middleware.ts (Next.js)
44
+ export const config = { matcher: ['/((?!api/payments/webhook).*)'] };
45
+ ```
46
+
47
+ Exempt the **one** path, by exact match, and make it the only route in your app
48
+ that skips CSRF. A prefix match on `/api/payments` exempts the checkout endpoint
49
+ too, which is where the money is.
50
+
51
+ ---
52
+
53
+ Back to [`SKILL.md`](../SKILL.md) — *Webhook signature verification* is the gate these two
54
+ sit behind, and *Test matrix* in
55
+ [`testing-and-local-dev.md`](testing-and-local-dev.md) carries the non-allowlisted-IP case.