@ssheleg/sheleg-dev 0.7.0 → 0.9.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 +114 -0
- package/README.md +24 -9
- package/SECURITY.md +28 -18
- package/package.json +4 -2
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +7 -3
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +5 -4
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +168 -20
- package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +6 -2
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +17 -80
- package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +55 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/testing-and-local-dev.md +60 -0
- package/plugins/sheleg-dev/skills/error-tracking/SKILL.md +259 -0
- package/plugins/sheleg-dev/skills/error-tracking/references/releases.md +159 -0
- package/plugins/sheleg-dev/skills/error-tracking/references/scrubbing.md +170 -0
- package/plugins/sheleg-dev/skills/error-tracking/references/setup.md +176 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +5 -4
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +195 -35
- package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +4 -3
|
@@ -77,7 +77,7 @@ export const INVARIANTS = [
|
|
|
77
77
|
fixtures: [PIXEL_BROWSER, CAPI_SERVER],
|
|
78
78
|
states: 'one id, generated once and used by both sides, or the revenue counts twice',
|
|
79
79
|
breaks: ['shared-event-id'],
|
|
80
|
-
run({ sink, emitter }) {
|
|
80
|
+
run({ sink, emitter, assert }) {
|
|
81
81
|
runPurchase({ sink, emitter }, CLEARED());
|
|
82
82
|
// The browser side is read from the SHIPPED fixture rather than from the sink. That
|
|
83
83
|
// is deliberate: it keeps this assertion independent of whether the browser event
|
|
@@ -95,7 +95,7 @@ export const INVARIANTS = [
|
|
|
95
95
|
fixtures: [PIXEL_BROWSER, CAPI_SERVER],
|
|
96
96
|
states: 'Meta compares the name as well as the id, and the name is the half that is missed',
|
|
97
97
|
breaks: ['exact-event-name'],
|
|
98
|
-
run({ sink, emitter }) {
|
|
98
|
+
run({ sink, emitter, assert }) {
|
|
99
99
|
runPurchase({ sink, emitter }, CLEARED());
|
|
100
100
|
const pixelName = fixture(PIXEL_BROWSER)[1]; // the browser contract, not the sink
|
|
101
101
|
const serverName = sink.capi[0].data[0].event_name;
|
|
@@ -110,7 +110,7 @@ export const INVARIANTS = [
|
|
|
110
110
|
fixtures: [CAPI_FORBIDDEN],
|
|
111
111
|
states: 'the thank-you page has no idea whether the money moved, so it reports nothing',
|
|
112
112
|
breaks: ['webhook-sourced'],
|
|
113
|
-
run({ sink, emitter }) {
|
|
113
|
+
run({ sink, emitter, assert }) {
|
|
114
114
|
runPurchase({ sink, emitter }, NOT_CLEARED());
|
|
115
115
|
assert.equal(sink.capi.length, 0,
|
|
116
116
|
'a Conversions API purchase was sent for a charge that has not cleared — this is '
|
|
@@ -125,12 +125,16 @@ export const INVARIANTS = [
|
|
|
125
125
|
fixtures: [PIXEL_BROWSER],
|
|
126
126
|
states: 'the browser event stays and stays subordinate — it carries the click ids',
|
|
127
127
|
breaks: ['keep-browser-event'],
|
|
128
|
-
run({ sink, emitter }) {
|
|
128
|
+
run({ sink, emitter, assert }) {
|
|
129
129
|
runPurchase({ sink, emitter }, CLEARED());
|
|
130
130
|
assert.equal(sink.pixel.length, 1,
|
|
131
131
|
'no browser event fired: the click id, the consent state and the session are lost, '
|
|
132
132
|
+ 'and only the browser had them');
|
|
133
|
-
|
|
133
|
+
// `|| []` on purpose: with no browser event at all this must FAIL rather than throw a
|
|
134
|
+
// TypeError, because an assertion the runner never reached is one no mutant has been
|
|
135
|
+
// watched breaking — which is how it sat unmeasured until 2026-08-20.
|
|
136
|
+
assert.equal((sink.pixel[0] || [])[0], 'track',
|
|
137
|
+
'the browser event is not a `track` call — the pixel contract is `fbq(\'track\', …)`');
|
|
134
138
|
},
|
|
135
139
|
},
|
|
136
140
|
{
|
|
@@ -138,7 +142,7 @@ export const INVARIANTS = [
|
|
|
138
142
|
fixtures: [CAPI_SERVER],
|
|
139
143
|
states: 'advanced matching sends hashes, never the address itself',
|
|
140
144
|
breaks: ['hashed-identifiers'],
|
|
141
|
-
run({ sink, emitter }) {
|
|
145
|
+
run({ sink, emitter, assert }) {
|
|
142
146
|
runPurchase({ sink, emitter }, CLEARED());
|
|
143
147
|
const userData = sink.capi[0].data[0].user_data;
|
|
144
148
|
for (const value of userData.em) {
|
|
@@ -156,7 +160,7 @@ export const INVARIANTS = [
|
|
|
156
160
|
// Broad by construction: it compares the whole body, so every rule that changes any
|
|
157
161
|
// field turns it red. It is here so a reader can trust the fixtures they copy.
|
|
158
162
|
breaks: ['shared-event-id', 'exact-event-name', 'hashed-identifiers', 'keep-browser-event'],
|
|
159
|
-
run({ sink, emitter }) {
|
|
163
|
+
run({ sink, emitter, assert }) {
|
|
160
164
|
runPurchase({ sink, emitter }, CLEARED());
|
|
161
165
|
assert.deepEqual(sink.capi[0], fixture(CAPI_SERVER));
|
|
162
166
|
assert.deepEqual(sink.pixel[0], fixture(PIXEL_BROWSER));
|
|
@@ -164,46 +168,166 @@ export const INVARIANTS = [
|
|
|
164
168
|
},
|
|
165
169
|
];
|
|
166
170
|
|
|
171
|
+
// ------------------------------------------------- one assertion, one measurement
|
|
172
|
+
//
|
|
173
|
+
// Why the assertions are not called straight through `node:assert`. Until 2026-08-20 the
|
|
174
|
+
// runner returned on the FIRST throw and the matrix compared one row per invariant, so an
|
|
175
|
+
// invariant was measured as a single unit: any assertion that was not the sole
|
|
176
|
+
// discriminator could be replaced by `assert.ok(true)` and nothing went red. Measured that
|
|
177
|
+
// day in this pack too — the PII guard below (*"user_data carries something with an @ in
|
|
178
|
+
// it"*, advertised by name at `references/meta-linkedin.md`) was neutered and
|
|
179
|
+
// `--self-test` stayed at exit 0, because the `assert.match` above it discriminated the
|
|
180
|
+
// same rule.
|
|
181
|
+
//
|
|
182
|
+
// So the `assert` an invariant receives is a RECORDER: same API, but a failure is
|
|
183
|
+
// remembered instead of thrown and the assertions after it still run. Each call site is one
|
|
184
|
+
// assertion with its own identity — its line in this file — and `--self-test` asks the
|
|
185
|
+
// question per assertion rather than per invariant.
|
|
186
|
+
//
|
|
187
|
+
// `assert.unmutated.equal(...)` marks an assertion NO mutant in this pack varies: it pins
|
|
188
|
+
// something the rules do not touch, so it has never been watched failing and the verdict
|
|
189
|
+
// counts it apart rather than as evidence. Measured, not asserted — the self-test fails
|
|
190
|
+
// both when a plain assertion turns out unbreakable and when an `unmutated` one turns out
|
|
191
|
+
// breakable.
|
|
192
|
+
//
|
|
193
|
+
// Duplicated from `stripe-billing/fixtures/assert-money-invariants.mjs` for the reason
|
|
194
|
+
// given at the top: a reader installs one skill, not six.
|
|
195
|
+
|
|
196
|
+
const SELF = import.meta.url;
|
|
197
|
+
const METHODS = ['equal', 'notEqual', 'deepEqual', 'notDeepEqual', 'ok', 'match', 'doesNotMatch'];
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The line in THIS file that called the recorder.
|
|
201
|
+
*
|
|
202
|
+
* `captureStackTrace(holder, boundary)` drops `boundary` and everything above it, so the
|
|
203
|
+
* top frame is the assertion's own line — no frame counting, which is what makes the
|
|
204
|
+
* identity survive a refactor of the recorder itself.
|
|
205
|
+
*/
|
|
206
|
+
function siteOf(boundary) {
|
|
207
|
+
const holder = {};
|
|
208
|
+
Error.captureStackTrace(holder, boundary);
|
|
209
|
+
const frame = (holder.stack || '').split('\n').slice(1).find((f) => f.includes(SELF));
|
|
210
|
+
const m = frame && frame.match(/:(\d+):\d+\)?\s*$/);
|
|
211
|
+
return m ? Number(m[1]) : 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function recorder() {
|
|
215
|
+
const seen = new Map(); // line -> { site, unmutated, ok, why }
|
|
216
|
+
|
|
217
|
+
function record(site, unmutated, name, args) {
|
|
218
|
+
let ok = true;
|
|
219
|
+
let why = null;
|
|
220
|
+
try {
|
|
221
|
+
assert[name](...args);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
ok = false;
|
|
224
|
+
why = error.message.split('\n')[0];
|
|
225
|
+
}
|
|
226
|
+
const prev = seen.get(site);
|
|
227
|
+
if (prev) {
|
|
228
|
+
prev.ok = prev.ok && ok;
|
|
229
|
+
prev.why = prev.why || why;
|
|
230
|
+
} else {
|
|
231
|
+
seen.set(site, { site, unmutated, ok, why });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const bind = (unmutated) => {
|
|
236
|
+
const api = {};
|
|
237
|
+
for (const name of METHODS) {
|
|
238
|
+
const call = (...args) => record(siteOf(call), unmutated, name, args);
|
|
239
|
+
api[name] = call;
|
|
240
|
+
}
|
|
241
|
+
return api;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const api = bind(false);
|
|
245
|
+
api.unmutated = bind(true);
|
|
246
|
+
return { api, seen };
|
|
247
|
+
}
|
|
248
|
+
|
|
167
249
|
// ------------------------------------------------------------------------- runners
|
|
168
250
|
|
|
169
251
|
async function runOne(invariant, without) {
|
|
170
|
-
const
|
|
252
|
+
const { api, seen } = recorder();
|
|
253
|
+
const { sink, emitter } = harness(without);
|
|
254
|
+
let threw = null;
|
|
171
255
|
try {
|
|
172
|
-
await invariant.run(
|
|
173
|
-
return null;
|
|
256
|
+
await invariant.run({ sink, emitter, assert: api });
|
|
174
257
|
} catch (error) {
|
|
175
|
-
|
|
258
|
+
// A real error — a read off a row the mutant never wrote, say. It aborts the remaining
|
|
259
|
+
// assertions, and it is still the invariant going red.
|
|
260
|
+
threw = error.message.split('\n')[0];
|
|
176
261
|
}
|
|
262
|
+
const sites = [...seen.values()];
|
|
263
|
+
return {
|
|
264
|
+
threw,
|
|
265
|
+
sites,
|
|
266
|
+
failed: sites.filter((s) => !s.ok),
|
|
267
|
+
broke: Boolean(threw) || sites.some((s) => !s.ok),
|
|
268
|
+
};
|
|
177
269
|
}
|
|
178
270
|
|
|
179
271
|
async function runAll() {
|
|
180
272
|
let failed = 0;
|
|
273
|
+
let asserted = 0;
|
|
181
274
|
for (const invariant of INVARIANTS) {
|
|
182
|
-
const
|
|
183
|
-
|
|
275
|
+
const result = await runOne(invariant, []);
|
|
276
|
+
asserted += result.sites.length;
|
|
277
|
+
if (result.broke) {
|
|
184
278
|
failed += 1;
|
|
185
|
-
console.error(`FAIL ${invariant.id}
|
|
279
|
+
console.error(`FAIL ${invariant.id}`);
|
|
280
|
+
for (const s of result.failed) console.error(` :${s.site} ${s.why}`);
|
|
281
|
+
if (result.threw) console.error(` threw: ${result.threw}`);
|
|
186
282
|
} else {
|
|
187
|
-
console.log(`pass ${invariant.id} — ${invariant.states}`);
|
|
283
|
+
console.log(`pass ${invariant.id} (${result.sites.length} assertions) — ${invariant.states}`);
|
|
188
284
|
}
|
|
189
285
|
}
|
|
190
286
|
if (failed) {
|
|
191
287
|
console.error(`\n${failed} of ${INVARIANTS.length} deduplication assertions failed`);
|
|
192
288
|
return 1;
|
|
193
289
|
}
|
|
194
|
-
console.log(`\nOK: ${INVARIANTS.length} deduplication
|
|
290
|
+
console.log(`\nOK: ${INVARIANTS.length} deduplication invariants hold against the emitter `
|
|
291
|
+
+ `(${asserted} assertions)`);
|
|
195
292
|
return 0;
|
|
196
293
|
}
|
|
197
294
|
|
|
198
295
|
async function selfTest() {
|
|
199
296
|
const problems = [];
|
|
200
297
|
const matrix = [];
|
|
298
|
+
const coverage = []; // one row per ASSERTION: which mutants turn that call site red
|
|
201
299
|
for (const invariant of INVARIANTS) {
|
|
300
|
+
// The clean run is the census: it names every assertion this invariant executes. One
|
|
301
|
+
// that records nothing has a run() which never took the recorder, so nothing about it
|
|
302
|
+
// is measured — the failure mode of the injection itself.
|
|
303
|
+
const clean = await runOne(invariant, []);
|
|
304
|
+
if (clean.broke) {
|
|
305
|
+
problems.push(`${invariant.id}: fails against the unmutated emitter `
|
|
306
|
+
+ `(${(clean.failed[0] && `:${clean.failed[0].site} ${clean.failed[0].why}`) || clean.threw})`);
|
|
307
|
+
}
|
|
308
|
+
if (!clean.sites.length) {
|
|
309
|
+
problems.push(`${invariant.id}: recorded no assertion — its run() does not take the `
|
|
310
|
+
+ '`assert` the runner passes it, so nothing about it is measured');
|
|
311
|
+
}
|
|
312
|
+
const broken = new Map(clean.sites.map((s) => [s.site, []]));
|
|
313
|
+
|
|
202
314
|
const broke = [];
|
|
203
315
|
for (const rule of RULES) {
|
|
204
|
-
|
|
316
|
+
const r = await runOne(invariant, [rule]);
|
|
317
|
+
if (r.broke) broke.push(rule);
|
|
318
|
+
for (const s of r.failed) if (broken.has(s.site)) broken.get(s.site).push(rule);
|
|
205
319
|
}
|
|
206
320
|
matrix.push({ id: invariant.id, broke });
|
|
321
|
+
|
|
322
|
+
for (const site of clean.sites) {
|
|
323
|
+
coverage.push({
|
|
324
|
+
invariant: invariant.id,
|
|
325
|
+
site: site.site,
|
|
326
|
+
unmutated: site.unmutated,
|
|
327
|
+
by: broken.get(site.site) || [],
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
207
331
|
const expected = [...invariant.breaks].sort().join(', ');
|
|
208
332
|
const measured = [...broke].sort().join(', ');
|
|
209
333
|
if (expected !== measured) {
|
|
@@ -215,12 +339,33 @@ async function selfTest() {
|
|
|
215
339
|
}
|
|
216
340
|
|
|
217
341
|
const width = Math.max(...matrix.map((row) => row.id.length));
|
|
218
|
-
console.log(`\
|
|
219
|
-
+ 'and which removed rule turns each
|
|
342
|
+
console.log(`\ninvariant x mutant — ${RULES.length} mutants, `
|
|
343
|
+
+ 'and which removed rule turns each invariant red\n');
|
|
220
344
|
for (const row of matrix) {
|
|
221
345
|
console.log(` ${row.id.padEnd(width)} ${row.broke.join(', ') || '(nothing)'}`);
|
|
222
346
|
}
|
|
223
347
|
|
|
348
|
+
// The per-ASSERTION half, and the one the table above cannot give: an invariant goes red
|
|
349
|
+
// as a unit, so a dead assertion inside a live one is invisible there.
|
|
350
|
+
console.log('\nassertion x mutant — every call site, and what turns IT red\n');
|
|
351
|
+
for (const row of coverage) {
|
|
352
|
+
const label = `${row.invariant}:${row.site}`.padEnd(width + 6);
|
|
353
|
+
if (row.unmutated) {
|
|
354
|
+
console.log(` ${label} unmutated — no rule varies what it checks`);
|
|
355
|
+
if (row.by.length) {
|
|
356
|
+
problems.push(`${row.invariant} :${row.site} is declared assert.unmutated and `
|
|
357
|
+
+ `[${row.by.join(', ')}] breaks it — it discriminates, so drop the .unmutated`);
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
console.log(` ${label} ${row.by.join(', ') || '(nothing)'}`);
|
|
361
|
+
if (!row.by.length) {
|
|
362
|
+
problems.push(`${row.invariant} :${row.site} is broken by no mutant — the assertion `
|
|
363
|
+
+ 'cannot fail. Either it is not testing anything, or no rule varies what it '
|
|
364
|
+
+ 'checks and it belongs on assert.unmutated, which the verdict counts apart');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
224
369
|
console.log('\nisolation — the fixture that separates each rule from every other\n');
|
|
225
370
|
for (const rule of RULES) {
|
|
226
371
|
const isolating = matrix.filter((row) => row.broke.length === 1 && row.broke[0] === rule);
|
|
@@ -237,7 +382,10 @@ async function selfTest() {
|
|
|
237
382
|
for (const p of problems) console.error(` - ${p}`);
|
|
238
383
|
return 1;
|
|
239
384
|
}
|
|
240
|
-
|
|
385
|
+
const pinned = coverage.filter((r) => r.unmutated).length;
|
|
386
|
+
console.log(`\nOK: ${INVARIANTS.length} invariants over ${coverage.length} assertions — `
|
|
387
|
+
+ `${coverage.length - pinned} watched failing ONE CALL SITE AT A TIME against `
|
|
388
|
+
+ `${RULES.length} mutants; ${pinned} declared unmutated and measured unbreakable; `
|
|
241
389
|
+ `${RULES.length} rules, each isolated by a fixture`);
|
|
242
390
|
return 0;
|
|
243
391
|
}
|
|
@@ -102,8 +102,12 @@ 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
103
|
matching mechanism, not a permission. That the wrapper receives a hash at all is checkable:
|
|
104
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.
|
|
106
|
-
|
|
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.
|
|
107
111
|
|
|
108
112
|
Meta's Business Tools Terms prohibit sharing data that "includes or is based on"
|
|
109
113
|
any of these, and the prohibition covers the Pixel, the Conversions API, the
|
|
@@ -39,15 +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
47
|
- [The test/live credential boundary](#the-testlive-credential-boundary)
|
|
49
|
-
- [Local development](#local-development)
|
|
50
|
-
- [Test matrix](#test-matrix)
|
|
48
|
+
- [Local development and the test matrix](#local-development-and-the-test-matrix)
|
|
51
49
|
- [Security checklist](#security-checklist)
|
|
52
50
|
- [Common pitfalls](#common-pitfalls)
|
|
53
51
|
|
|
@@ -192,46 +190,17 @@ the read.
|
|
|
192
190
|
|
|
193
191
|
---
|
|
194
192
|
|
|
195
|
-
##
|
|
193
|
+
## Callback route hardening
|
|
196
194
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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.
|
|
200
200
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const xff = req.headers['x-forwarded-for'];
|
|
205
|
-
if (typeof xff === 'string') {
|
|
206
|
-
const hops = xff.split(',').map((s) => s.trim());
|
|
207
|
-
return hops[hops.length - TRUSTED_PROXY_COUNT] ?? null;
|
|
208
|
-
}
|
|
209
|
-
return req.socket.remoteAddress ?? null;
|
|
210
|
-
}
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
Taking `xff[0]` trusts a header the client controls — anyone can claim to be the
|
|
214
|
-
gateway. Count from the right by the number of proxies you actually run, and
|
|
215
|
-
keep that number in configuration, because it changes when infrastructure does.
|
|
216
|
-
|
|
217
|
-
Allowlist the provider's published callback IPs, log a rejection with the
|
|
218
|
-
observed IP, and **never** fall back to "allow if the header is missing".
|
|
219
|
-
|
|
220
|
-
---
|
|
221
|
-
|
|
222
|
-
## CSRF exemption for callback routes
|
|
223
|
-
|
|
224
|
-
A gateway cannot present your CSRF token. Exempt the callback path explicitly
|
|
225
|
-
and narrowly:
|
|
226
|
-
|
|
227
|
-
```ts
|
|
228
|
-
// middleware.ts (Next.js)
|
|
229
|
-
export const config = { matcher: ['/((?!api/payments/webhook).*)'] };
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
Exempt the **one** path, by exact match, and make it the only route in your app
|
|
233
|
-
that skips CSRF. A prefix match on `/api/payments` exempts the checkout endpoint
|
|
234
|
-
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.
|
|
235
204
|
|
|
236
205
|
---
|
|
237
206
|
|
|
@@ -374,45 +343,13 @@ boundary* and *Residual exposure*.
|
|
|
374
343
|
|
|
375
344
|
---
|
|
376
345
|
|
|
377
|
-
## Local development
|
|
378
|
-
|
|
379
|
-
Two paths, and you want both:
|
|
380
|
-
|
|
381
|
-
**Skip the gateway.** A `SKIP_BILLING=true` branch that credits immediately and
|
|
382
|
-
logs loudly. Everyone building a feature that merely *touches* checkout should
|
|
383
|
-
use this. Make it impossible in production: assert `NODE_ENV !== 'production'`
|
|
384
|
-
at the branch, not just in config.
|
|
385
|
-
|
|
386
|
-
**Real gateway, tunnelled callback.** Callbacks cannot reach `localhost`:
|
|
387
|
-
|
|
388
|
-
```bash
|
|
389
|
-
cloudflared tunnel --url http://localhost:3000
|
|
390
|
-
# → https://random-name.trycloudflare.com → set as the callback base URL, restart the app
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
Then keep a **signed mock callback** in your test tooling — a script that builds
|
|
394
|
-
a real payload, signs it with the dev key and POSTs it. This is what makes the
|
|
395
|
-
sad paths (`wrong_amount`, `refund_fail`, `locked`) testable at all; you cannot
|
|
396
|
-
ask a gateway to underpay you on demand.
|
|
397
|
-
|
|
398
|
-
---
|
|
399
|
-
|
|
400
|
-
## Test matrix
|
|
401
|
-
|
|
402
|
-
Cover these, and plant a defect against each before believing the green:
|
|
346
|
+
## Local development and the test matrix
|
|
403
347
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
| duplicate delivery of a settling status | idempotency guard |
|
|
410
|
-
| `paid_over` | crediting the received amount, not the invoiced |
|
|
411
|
-
| `wrong_amount` | no full credit on a partial payment |
|
|
412
|
-
| out-of-order delivery (final, then earlier) | the `notIn` guard, not timestamps |
|
|
413
|
-
| callback from a non-allowlisted IP | proxy-aware extraction |
|
|
414
|
-
| `refund_process` then `refund_fail` | refund is a state machine |
|
|
415
|
-
| `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).
|
|
416
353
|
|
|
417
354
|
---
|
|
418
355
|
|
|
@@ -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.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Testing and local development
|
|
2
|
+
|
|
3
|
+
Split out of `SKILL.md` on 2026-08-20 for the same reason as
|
|
4
|
+
[`callback-route-hardening.md`](callback-route-hardening.md): the body was ~4894 tokens
|
|
5
|
+
against a 4750 working limit, and the house answer at that point is a split rather than a
|
|
6
|
+
trim. This is the operational half — how you run the thing on a laptop, and what you have
|
|
7
|
+
to cover before believing a green suite.
|
|
8
|
+
|
|
9
|
+
`stripe-billing` ships the same pair of subjects at
|
|
10
|
+
[`../../stripe-billing/references/testing-and-local-dev.md`](../../stripe-billing/references/testing-and-local-dev.md),
|
|
11
|
+
and its *Mutation testing* section is the argument this matrix assumes: plant a defect
|
|
12
|
+
against every row before believing it.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Local development
|
|
17
|
+
|
|
18
|
+
Two paths, and you want both:
|
|
19
|
+
|
|
20
|
+
**Skip the gateway.** A `SKIP_BILLING=true` branch that credits immediately and
|
|
21
|
+
logs loudly. Everyone building a feature that merely *touches* checkout should
|
|
22
|
+
use this. Make it impossible in production: assert `NODE_ENV !== 'production'`
|
|
23
|
+
at the branch, not just in config.
|
|
24
|
+
|
|
25
|
+
**Real gateway, tunnelled callback.** Callbacks cannot reach `localhost`:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cloudflared tunnel --url http://localhost:3000
|
|
29
|
+
# → https://random-name.trycloudflare.com → set as the callback base URL, restart the app
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then keep a **signed mock callback** in your test tooling — a script that builds
|
|
33
|
+
a real payload, signs it with the dev key and POSTs it. This is what makes the
|
|
34
|
+
sad paths (`wrong_amount`, `refund_fail`, `locked`) testable at all; you cannot
|
|
35
|
+
ask a gateway to underpay you on demand.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Test matrix
|
|
40
|
+
|
|
41
|
+
Cover these, and plant a defect against each before believing the green:
|
|
42
|
+
|
|
43
|
+
| Case | What it catches |
|
|
44
|
+
|---|---|
|
|
45
|
+
| valid signature | the happy path |
|
|
46
|
+
| tampered signature | constant-time compare, 403 not 500 |
|
|
47
|
+
| signature over a payload containing a URL | the escaping trap |
|
|
48
|
+
| duplicate delivery of a settling status | idempotency guard |
|
|
49
|
+
| `paid_over` | crediting the received amount, not the invoiced |
|
|
50
|
+
| `wrong_amount` | no full credit on a partial payment |
|
|
51
|
+
| out-of-order delivery (final, then earlier) | the `notIn` guard, not timestamps |
|
|
52
|
+
| callback from a non-allowlisted IP | proxy-aware extraction |
|
|
53
|
+
| `refund_process` then `refund_fail` | refund is a state machine |
|
|
54
|
+
| `locked` | no credit, no auto-cancel |
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
Back to [`SKILL.md`](../SKILL.md). The provider-specific half — one credential, no sandbox
|
|
59
|
+
host, and what "test mode" actually toggles — is
|
|
60
|
+
[`heleket-provider.md`](heleket-provider.md), *The test/live boundary*.
|