@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.
- package/CHANGELOG.md +318 -0
- package/README.md +115 -1
- package/SECURITY.md +199 -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 +7 -3
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +77 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +397 -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/meta-linkedin.md +17 -1
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +82 -77
- package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +55 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
- package/plugins/sheleg-dev/skills/crypto-payments/references/testing-and-local-dev.md +60 -0
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +1 -1
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +111 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +564 -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/testing-and-local-dev.md +39 -2
- 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
|
-
- [
|
|
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
|
-
- [
|
|
49
|
-
- [
|
|
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
|
-
##
|
|
193
|
+
## Callback route hardening
|
|
195
194
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
##
|
|
346
|
+
## Local development and the test matrix
|
|
338
347
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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.
|