@ssheleg/sheleg-dev 0.11.8 → 0.13.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 +47 -0
- package/package.json +4 -3
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +35 -8
- package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +57 -12
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +39 -26
- package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +52 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +42 -23
- package/plugins/sheleg-dev/skills/error-tracking/SKILL.md +34 -6
- package/plugins/sheleg-dev/skills/error-tracking/references/scrubbing.md +65 -2
- package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +62 -23
- package/plugins/sheleg-dev/skills/google-auth/SKILL.md +22 -1
- package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +13 -3
- package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +150 -64
- package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +36 -19
- package/plugins/sheleg-dev/skills/google-signin/SKILL.md +64 -23
- package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +150 -49
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +26 -9
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +17 -1
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +171 -5
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +333 -198
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +259 -35
- package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +149 -28
- package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +76 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
## v0.13.0 — the gate exists on one surface, and every skill now says so
|
|
2
|
+
|
|
3
|
+
This pack ships a `PreToolUse` money gate (`hooks/money-gate.js`) that refuses a refund,
|
|
4
|
+
a payout, a dispute close, a live `sk_live_…` key and `SKIP_BILLING=true` in production
|
|
5
|
+
until the authorised person has signed that category off. It runs on Claude Code and
|
|
6
|
+
nowhere else. Measured 2026-09-14: of the seven skills, **one** (`error-tracking`) wrote
|
|
7
|
+
a degradation contract; `stripe-billing` never mentioned the gate at all and
|
|
8
|
+
`crypto-payments` named it and sent the consequences to the README — so the two skills
|
|
9
|
+
the gate exists FOR were the two that did not say what happens without it.
|
|
10
|
+
|
|
11
|
+
- **`## Degradation` in all seven skill bodies**, one line per axis, in the shape
|
|
12
|
+
`references/host-capabilities.md` requires: not-Claude-Code, installed-by-copy, and the
|
|
13
|
+
capability each skill's own work needs (Stripe CLI, node fixtures, a browser channel, a
|
|
14
|
+
console login).
|
|
15
|
+
- **`check_degradation_is_written_where_a_host_capability_ships`** — a pack that ships
|
|
16
|
+
`hooks/hooks.json` requires the section in every skill body. Watched failing on four
|
|
17
|
+
skills before they were written.
|
|
18
|
+
- **The body budget is MEASURED, not estimated.** This gate divided by 3.9 chars/token
|
|
19
|
+
while the family's CI auditor measures with tiktoken, and the two disagree by up to 8%
|
|
20
|
+
in the same direction: `crypto-payments` estimated **4986** and measured **4618** — a
|
|
21
|
+
verdict of *past the working limit* over 132 tokens of real headroom, which is what
|
|
22
|
+
blocked this change from landing. With no tokenizer installed the check now DISCLOSES
|
|
23
|
+
that it could not measure instead of failing a file it never counted. make-skill
|
|
24
|
+
v0.28.0 closed exactly this for the family auditor; this repo kept the estimate and so
|
|
25
|
+
kept the defect.
|
|
26
|
+
- **The `биллинг` trigger is now `биллинг подписок`.** Bare `биллинг` collided with
|
|
27
|
+
`agent-orchestrator`'s `биллинг LLM` across packs, where no per-repo validator can see
|
|
28
|
+
it: this one meters what an agent burned, that one charges a card.
|
|
29
|
+
- `docs/evals/stripe-billing.md` restates a measured figure (4521 tokens, tiktoken)
|
|
30
|
+
rather than a heuristic one, and the validator compares it to a measurement.
|
|
31
|
+
|
|
32
|
+
## v0.12.0 — the integration doctrine closes its audit, and a crypto reference gains its map
|
|
33
|
+
|
|
34
|
+
Sherlock external-v3 (43 findings) — the largest share in the family — each with
|
|
35
|
+
its own executable regression.
|
|
36
|
+
|
|
37
|
+
- Offline fault-injection corpora for every integration surface: card money,
|
|
38
|
+
crypto, ad tracking, error tracking, sign-in and page speed. Each case injects a
|
|
39
|
+
fault and judges it with a STATE or OUTPUT oracle rather than a provider stub
|
|
40
|
+
judging itself, and each counterexample fails before the fix and passes after.
|
|
41
|
+
- `test/negatives.py` stops reading an environment failure as a proven validation
|
|
42
|
+
gap: explicit stages, a resource preflight, a per-step timeout, and cleanup in a
|
|
43
|
+
`finally`. A full disk once printed forty "the guard does not actually fire"
|
|
44
|
+
findings; it now prints TEST_ERROR and zero guard-bypass findings.
|
|
45
|
+
- `crypto-payments/references/callback-route-hardening.md` gained a `## Contents`.
|
|
46
|
+
- CI now MEASURES the token budget with a real tokenizer instead of estimating it.
|
|
47
|
+
|
|
1
48
|
## v0.11.8 — a Contents entry pointing nowhere is depth the file does not have
|
|
2
49
|
|
|
3
50
|
The family audit's second round (2026-09-07) read the eight reference files that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/sheleg-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Seven integration skills for the layer a product reaches once it has users: Stripe subscription billing and cancellation retention, crypto payments, ad and conversion tracking under Consent Mode v2, Google sign-in and the wider Google auth surface, error tracking, and Core Web Vitals. This package is the installer CLI.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sheleg-dev": "bin/sheleg-dev.js"
|
|
@@ -43,9 +43,10 @@
|
|
|
43
43
|
"skill"
|
|
44
44
|
],
|
|
45
45
|
"scripts": {
|
|
46
|
-
"test": "python3 test/validate.py && node test/moneygate_test.js && node test/fixtures_test.js && node test/installer_test.js",
|
|
46
|
+
"test": "python3 test/validate.py && node test/moneygate_test.js && node test/fixtures_test.js && node test/installer_test.js && npm run test:audit",
|
|
47
47
|
"test:negatives": "python3 test/negatives.py",
|
|
48
48
|
"test:schemas": "python3 test/check_schemas.py",
|
|
49
|
-
"test:all": "python3 test/validate.py && node test/moneygate_test.js && node test/fixtures_test.js && node test/installer_test.js && python3 test/negatives.py"
|
|
49
|
+
"test:all": "python3 test/validate.py && node test/moneygate_test.js && node test/fixtures_test.js && node test/installer_test.js && python3 test/negatives.py && npm run test:audit",
|
|
50
|
+
"test:audit": "for t in test/audit_regressions/*.py; do python3 \"$t\" || exit 1; done"
|
|
50
51
|
}
|
|
51
52
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "sheleg-dev",
|
|
4
4
|
"displayName": "Sheleg Dev",
|
|
5
5
|
"description": "The integration layer a product needs once it has users: Stripe subscription billing reconciled into your own database, crypto payments that survive over-payment and duplicate webhooks, ad and conversion tracking under Consent Mode v2, Google sign-in with the account-linking guards, the wider Google auth surface, and Core Web Vitals work that moves the score, and Sentry error tracking wired so it does not forward your own credentials to a third party and so a stack trace names the commit that caused it.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.13.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "ssheleg",
|
|
9
9
|
"url": "https://x.com/sshlg93"
|
|
@@ -75,8 +75,10 @@ defaults after the script has loaded looks correct in every test that begins by
|
|
|
75
75
|
accepting consent, and loses the denied population entirely.
|
|
76
76
|
|
|
77
77
|
**Denied is not off.** With Advanced mode Google still receives cookieless
|
|
78
|
-
pings and models conversions from them
|
|
79
|
-
|
|
78
|
+
pings and models conversions from them (Google's own dated case studies cite
|
|
79
|
+
~65–70%; yours is unknown until measured). Whether pre-consent pings are
|
|
80
|
+
lawful in your jurisdiction is counsel's call — Basic is the conservative
|
|
81
|
+
mode, and it still gets Google's general modeling.
|
|
80
82
|
## Google Analytics 4
|
|
81
83
|
|
|
82
84
|
### Setup
|
|
@@ -112,7 +114,10 @@ lost data. Blocking the script instead throws that away.
|
|
|
112
114
|
<!-- 3. Configure -->
|
|
113
115
|
<script>
|
|
114
116
|
gtag('js', new Date());
|
|
115
|
-
|
|
117
|
+
// GA4 config carries NO allow_enhanced_conversions — that flag belongs on the
|
|
118
|
+
// Google Ads `AW-` tag only (see the Enhanced-conversions section); on a `G-`
|
|
119
|
+
// tag it enables nothing and contradicts this skill's own rule.
|
|
120
|
+
gtag('config', 'G-XXXXXXXXXX');
|
|
116
121
|
</script>
|
|
117
122
|
```
|
|
118
123
|
|
|
@@ -230,15 +235,26 @@ fbq('init', 'PIXEL_ID');
|
|
|
230
235
|
fbq('track', 'PageView');
|
|
231
236
|
```
|
|
232
237
|
|
|
233
|
-
**noscript fallback
|
|
238
|
+
**noscript fallback — consent-gated, or omitted.** A bare `<noscript>` Meta
|
|
239
|
+
pixel FIRES on every JS-disabled page view — no banner, no `fbq`, no consent —
|
|
240
|
+
contradicting this stack's rule that Meta is *not rendered until consent*. So
|
|
241
|
+
emit it from the SERVER, only when a **server-verifiable consent** (a signed
|
|
242
|
+
HttpOnly cookie the server reads before writing the HTML) already exists. Cannot
|
|
243
|
+
verify consent server-side? **Omit it** — Meta's verification does not need it,
|
|
244
|
+
and a request that fired cannot be taken back:
|
|
234
245
|
|
|
235
246
|
```html
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
247
|
+
<!-- server-side: render ONLY when consent is proven -->
|
|
248
|
+
{% if consent_cookie_verified and consent.ad_storage == "granted" %}
|
|
249
|
+
<noscript><img height="1" width="1" style="display:none"
|
|
250
|
+
src="https://www.facebook.com/tr?id=PIXEL_ID&ev=PageView&noscript=1"/></noscript>
|
|
251
|
+
{% endif %}
|
|
240
252
|
```
|
|
241
253
|
|
|
254
|
+
Snippets here come from EXECUTABLE templates, so a static check refuses the two
|
|
255
|
+
mistakes this section prevents: `allow_enhanced_conversions` on a `G-` tag, and
|
|
256
|
+
a `noscript` Meta pixel not behind a server-verified consent condition.
|
|
257
|
+
|
|
242
258
|
### Standard Events
|
|
243
259
|
|
|
244
260
|
Meta defines 17 standard events. The four that carry a SaaS funnel are
|
|
@@ -398,3 +414,14 @@ when** line, so the trigger has one home and this table stays an index.
|
|
|
398
414
|
|
|
399
415
|
For the page-speed side of the same problem — what the tag does to LCP and TBT,
|
|
400
416
|
and what to do about it — see the `frontend-performance` skill in this pack.
|
|
417
|
+
|
|
418
|
+
## Degradation
|
|
419
|
+
|
|
420
|
+
- **Not Claude Code** (Cursor, Codex, the skills CLI, the API container): the pack's
|
|
421
|
+
`PreToolUse` gate does not run. It is the thing that refuses a live key or an
|
|
422
|
+
irreversible action in this pack; elsewhere those rules are advice you follow by hand.
|
|
423
|
+
- **Installed by copy rather than as a plugin**: the doctrine arrives, `hooks/` does not
|
|
424
|
+
— same loss, same remedy.
|
|
425
|
+
- **No network, or the ad platform's API unreachable**: server-side events cannot be
|
|
426
|
+
verified end to end. Say the dedup check did not run rather than reporting it held;
|
|
427
|
+
a browser pixel firing proves the browser, never the conversion.
|
|
@@ -20,24 +20,67 @@
|
|
|
20
20
|
## Status check — 2026
|
|
21
21
|
|
|
22
22
|
Consent Mode v2 has been required since **March 2024** for anyone using Google
|
|
23
|
-
advertising products with EEA or UK traffic
|
|
24
|
-
|
|
25
|
-
a hand-rolled banner
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
advertising products with EEA or UK traffic. The **certified CMP** requirement
|
|
24
|
+
(Consent Management Platform programme) is scoped to Google's **publisher**
|
|
25
|
+
products — AdSense, Ad Manager, AdMob — where a hand-rolled banner is no longer
|
|
26
|
+
sufficient; on the advertiser side (Google Ads, GA4) a certified CMP is
|
|
27
|
+
Google's recommendation, not a stated requirement. Verify the programme's
|
|
28
|
+
current scope and your CMP's certification before treating either box as
|
|
29
|
+
ticked. *(Checked 2026-08-06; scope per Google's CMP programme pages.)*
|
|
28
30
|
|
|
29
31
|
## Advanced vs Basic Mode
|
|
30
32
|
|
|
31
33
|
| Mode | Tags load before consent? | Cookieless pings? | Conversion modeling? |
|
|
32
34
|
|------|--------------------------|-------------------|---------------------|
|
|
33
|
-
| **Basic** | No — tags blocked until consent granted | No |
|
|
34
|
-
| **Advanced** | Yes — tags load with denied defaults | Yes |
|
|
35
|
+
| **Basic** | No — tags blocked until consent granted | No | **General** modeling only — Google models from aggregate patterns, less accurately; "no modeling" was never true |
|
|
36
|
+
| **Advanced** | Yes — tags load with denied defaults | Yes | **Advertiser-specific** modeling from your own cookieless pings |
|
|
37
|
+
|
|
38
|
+
**Which mode is three separate questions, not one default.** *Provider policy:*
|
|
39
|
+
Google's modeling is stronger under Advanced (advertiser-specific vs general).
|
|
40
|
+
*Business choice:* Advanced sends cookieless pings BEFORE consent — how much
|
|
41
|
+
recovered attribution is worth that is yours to decide. *Jurisdiction:* whether
|
|
42
|
+
pre-consent pings are lawful where your users are is counsel's question — some
|
|
43
|
+
EEA regulators read them restrictively, and Basic is the conservative answer.
|
|
44
|
+
The recovery figures Google has cited (~65–70%) come from its own dated,
|
|
45
|
+
campaign-specific case studies — **your recovery is unknown until measured on
|
|
46
|
+
your traffic**; an unmeasured percentage is not a promise.
|
|
47
|
+
|
|
48
|
+
**Two different technical contracts — never one snippet with a policy flag.**
|
|
49
|
+
Each mode has its own wiring and its own pre-consent behaviour, and mixing
|
|
50
|
+
their halves produces a page that behaves as neither:
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<!-- ADVANCED contract: denied defaults set synchronously, tags load at once.
|
|
54
|
+
Pre-consent behaviour: cookieless pings go out, no cookies are written. -->
|
|
55
|
+
<script>
|
|
56
|
+
window.dataLayer = window.dataLayer || [];
|
|
57
|
+
function gtag(){dataLayer.push(arguments);}
|
|
58
|
+
gtag('consent', 'default', { 'ad_storage': 'denied', 'ad_user_data': 'denied',
|
|
59
|
+
'ad_personalization': 'denied', 'analytics_storage': 'denied',
|
|
60
|
+
'wait_for_update': 500 });
|
|
61
|
+
</script>
|
|
62
|
+
<script async src="https://www.googletagmanager.com/gtag/js?id=TAG_ID"></script>
|
|
63
|
+
```
|
|
35
64
|
|
|
36
|
-
|
|
37
|
-
|
|
65
|
+
```html
|
|
66
|
+
<!-- BASIC contract: NO tag bytes reach the page before consent.
|
|
67
|
+
Pre-consent behaviour: zero requests to Google — nothing loads, nothing pings. -->
|
|
68
|
+
<script>
|
|
69
|
+
function loadTagsAfterConsent() { // called by the CMP's grant callback
|
|
70
|
+
const s = document.createElement('script');
|
|
71
|
+
s.async = true; s.src = 'https://www.googletagmanager.com/gtag/js?id=TAG_ID';
|
|
72
|
+
document.head.appendChild(s);
|
|
73
|
+
gtag('js', new Date()); gtag('config', 'TAG_ID');
|
|
74
|
+
}
|
|
75
|
+
</script>
|
|
76
|
+
```
|
|
38
77
|
|
|
39
|
-
|
|
40
|
-
|
|
78
|
+
**The mixed waterfall is the defect to test for**: gtag.js loaded eagerly (the
|
|
79
|
+
Advanced half) while the config call waits for consent (the Basic half) sends
|
|
80
|
+
requests the Basic policy said never happen and models nothing the Advanced
|
|
81
|
+
policy promised. A fixture for each mode asserts ITS pre-consent behaviour —
|
|
82
|
+
Advanced: pings without cookies; Basic: zero Google requests — and a page
|
|
83
|
+
failing both assertions is running the mix.
|
|
41
84
|
|
|
42
85
|
## All Consent Types
|
|
43
86
|
|
|
@@ -71,7 +114,9 @@ gtag('consent', 'default', {
|
|
|
71
114
|
'wait_for_update': 500
|
|
72
115
|
});
|
|
73
116
|
|
|
74
|
-
//
|
|
117
|
+
// Granted by default everywhere else — a recorded BUSINESS choice, not legal
|
|
118
|
+
// advice: whether a banner is needed outside the listed regions is a
|
|
119
|
+
// jurisdiction question (ePrivacy transpositions, US state laws, LGPD…).
|
|
75
120
|
gtag('consent', 'default', {
|
|
76
121
|
'ad_storage': 'granted',
|
|
77
122
|
'ad_user_data': 'granted',
|
|
@@ -179,26 +179,16 @@ Verify the signature **before** parsing anything into your domain, and return
|
|
|
179
179
|
|
|
180
180
|
Do not read-then-write. Make the database refuse the second delivery:
|
|
181
181
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// already final: a retry, or a late duplicate. Acknowledge and do nothing.
|
|
193
|
-
return res.status(200).json({ ok: true, duplicate: true });
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
await creditUser(...); // only reachable once per invoice
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
`updateMany` + a status guard is a compare-and-swap. `findFirst` followed by
|
|
200
|
-
`update` is a race, and the two deliveries that arrive 40ms apart will both pass
|
|
201
|
-
the read.
|
|
182
|
+
Three things are separate — the payment's LIFECYCLE status, the immutable
|
|
183
|
+
GRANT a confirmed settlement earns, and any refund/hold — and conflating them
|
|
184
|
+
credits money that never settled. The CAS (`updateMany` with a `status notIn
|
|
185
|
+
FINAL_STATUSES` guard) advances the LIFECYCLE only: it returns `count: 1` for
|
|
186
|
+
a transition to FAILED exactly as it does for PAID, so it is not permission to
|
|
187
|
+
credit. **Credit only a confirmed settlement (`mapped === 'PAID'`), once,
|
|
188
|
+
behind a UNIQUE per-invoice grant-ledger row atomic with the credit; refunds
|
|
189
|
+
and holds take their own path and are never swallowed as a duplicate.** The
|
|
190
|
+
worked handler is in
|
|
191
|
+
[`references/callback-route-hardening.md`](references/callback-route-hardening.md).
|
|
202
192
|
|
|
203
193
|
**Always return 200 for a duplicate.** A 409 makes the provider retry forever.
|
|
204
194
|
|
|
@@ -265,18 +255,26 @@ Write them on the settling webhook, in the same update that flips the status.
|
|
|
265
255
|
|
|
266
256
|
## Crediting: the amount waterfall
|
|
267
257
|
|
|
268
|
-
|
|
269
|
-
|
|
258
|
+
Four dimensions, and they never mix implicitly: **Money** (currency + minor
|
|
259
|
+
units — never floats), **Asset** (network + token + decimal amount),
|
|
260
|
+
**Entitlement** (plan units) and a **dated FX quote** (rate + source + time).
|
|
261
|
+
`paidAmountUsd` and `amountUsd` are Money; `tokenAmount` is an **Asset amount**
|
|
262
|
+
(the invoiced base + buffer — NOT plan units). A `??` across dimensions is an implicit
|
|
263
|
+
conversion, so first bring each candidate to USD minor — an Asset amount
|
|
264
|
+
converts only via a dated quote from a known source; an unknown or missing
|
|
265
|
+
quote **blocks** the conversion rather than guessing:
|
|
270
266
|
|
|
271
267
|
```ts
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
268
|
+
const usd = [payment.paidAmountUsd // Money: gateway's valuation
|
|
269
|
+
, toUsdMinor(payment.tokenAmount, quote) // Asset -> Money, dated quote or BLOCKED
|
|
270
|
+
, payment.amountUsd]; // Money: the intent — last resort
|
|
271
|
+
const credit = usd.find(v => v != null);
|
|
275
272
|
```
|
|
276
273
|
|
|
277
274
|
The order matters: valuing an over-payment at the intent silently keeps the
|
|
278
275
|
excess, and valuing an under-payment at the intent gives away product. Write an
|
|
279
|
-
audit row naming the source, or the first disputed balance
|
|
276
|
+
audit row naming the source AND the quote used, or the first disputed balance
|
|
277
|
+
is unprovable.
|
|
280
278
|
|
|
281
279
|
---
|
|
282
280
|
|
|
@@ -401,3 +399,18 @@ a green suite, each with the defect it catches:
|
|
|
401
399
|
| Cannot answer "how much did we net" | commission and `merchantAmount` never stored |
|
|
402
400
|
| 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 |
|
|
403
401
|
| Real invoices in a merchant dashboard nobody watches | a staging credential declared live — the mismatch nothing refused |
|
|
402
|
+
|
|
403
|
+
## Degradation
|
|
404
|
+
|
|
405
|
+
- **Not Claude Code** (Cursor, Codex, the skills CLI, the API container): the
|
|
406
|
+
`PreToolUse` gate named above — `plugins/sheleg-dev/hooks/money-gate.js` — does not
|
|
407
|
+
run there. Nothing refuses a payout, a refund, an exported `HELEKET_API_KEY` in a
|
|
408
|
+
`test` run, or `SKIP_BILLING=true` in production. The three lines are advice again,
|
|
409
|
+
which is exactly the state that made them ineffective for four releases: name the
|
|
410
|
+
surface you are on, and make the authorisation a human step out loud.
|
|
411
|
+
- **Installed by copy rather than as a plugin**: the doctrine arrives, the hook does
|
|
412
|
+
not. The README carries the categories and how one is authorised for a session.
|
|
413
|
+
- **No node, or the fixtures cannot run**: the invariant fixtures are the executable
|
|
414
|
+
half of this skill. Say the check did not run rather than reporting the invariant as
|
|
415
|
+
held — an unrun check and a passing one are indistinguishable from the exit code
|
|
416
|
+
nobody looked at.
|
|
@@ -9,6 +9,12 @@ mode that looks like "webhooks stopped working" rather than like a security even
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
+
## Contents
|
|
13
|
+
|
|
14
|
+
- [IP allowlisting behind a proxy](#ip-allowlisting-behind-a-proxy)
|
|
15
|
+
- [CSRF exemption for callback routes](#csrf-exemption-for-callback-routes)
|
|
16
|
+
- [Crediting: lifecycle, grant and refund are separate (DV-05)](#crediting-lifecycle-grant-and-refund-are-separate-dv-05)
|
|
17
|
+
|
|
12
18
|
## IP allowlisting behind a proxy
|
|
13
19
|
|
|
14
20
|
Signature verification is the real gate; the allowlist is defence in depth and
|
|
@@ -53,3 +59,49 @@ too, which is where the money is.
|
|
|
53
59
|
Back to [`SKILL.md`](../SKILL.md) — *Webhook signature verification* is the gate these two
|
|
54
60
|
sit behind, and *Test matrix* in
|
|
55
61
|
[`testing-and-local-dev.md`](testing-and-local-dev.md) carries the non-allowlisted-IP case.
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
## Crediting: lifecycle, grant and refund are separate (DV-05)
|
|
65
|
+
|
|
66
|
+
The CAS advances the payment's lifecycle; the GRANT is a second, independent
|
|
67
|
+
step gated on a confirmed settlement; refunds and holds have their own path.
|
|
68
|
+
Conflating them credits money that never settled — a webhook that transitions
|
|
69
|
+
a payment to FAILED passes the CAS (`count: 1`) exactly as PAID does.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// 1. Advance the lifecycle by compare-and-swap. Fires for ANY non-final
|
|
73
|
+
// transition — PAID, FAILED, UNDERPAID, REFUNDED alike — so it is NOT
|
|
74
|
+
// permission to credit. It only records what the provider said.
|
|
75
|
+
const { count } = await db.payment.updateMany({
|
|
76
|
+
where: { invoiceId, status: { notIn: FINAL_STATUSES } },
|
|
77
|
+
data: { status: mapped, paidAmount, txid, network, settledAt: new Date() },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// 2. Refunds and holds are their own path — never swallowed as a duplicate.
|
|
81
|
+
if (mapped === 'REFUNDED' || mapped === 'ON_HOLD') {
|
|
82
|
+
await recordRefundOrHold(invoiceId, mapped); // own ledger, own rules
|
|
83
|
+
return res.status(200).json({ ok: true });
|
|
84
|
+
}
|
|
85
|
+
if (count === 0) {
|
|
86
|
+
return res.status(200).json({ ok: true, duplicate: true }); // already advanced
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 3. CREDIT ONLY A CONFIRMED SETTLEMENT, and only once — an immutable grant
|
|
90
|
+
// row keyed by invoice is the business dedup, atomic with the credit.
|
|
91
|
+
// FAILED/UNDERPAID/pending advance the lifecycle above and grant NOTHING.
|
|
92
|
+
if (mapped === 'PAID') {
|
|
93
|
+
await db.$transaction(async (tx) => {
|
|
94
|
+
try {
|
|
95
|
+
await tx.grantLedger.create({ data: { invoiceId, amount: creditFor(invoiceId) } });
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (isUniqueViolation(e)) return; // this settlement already granted
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
await creditUser(tx, invoiceId);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`updateMany` + a status guard is a compare-and-swap for the LIFECYCLE, not the
|
|
106
|
+
grant: a CAS that advanced a payment to FAILED returns `count: 1` too, so the
|
|
107
|
+
credit hangs off `mapped === 'PAID'` and a UNIQUE grant row, never off the CAS.
|
|
@@ -111,7 +111,7 @@ sequenceDiagram
|
|
|
111
111
|
H->>W: POST { type:"payment", status:"paid", uuid, sign, ... }
|
|
112
112
|
W->>W: Verify caller IP + MD5 signature
|
|
113
113
|
W->>W: Type guard, idempotency check
|
|
114
|
-
W->>DB: $transaction:<br/> updateMany(status notIn final)<br/> creditAccountPurchasedTokens<br/> logTokenAudit(action:"crypto_topup")<br/> optional createBalanceSubscription / seat upgrade
|
|
114
|
+
W->>DB: $transaction (only if status==paid):<br/> updateMany(status notIn final)<br/> INSERT grantLedger(invoiceId) UNIQUE<br/> creditAccountPurchasedTokens<br/> logTokenAudit(action:"crypto_topup")<br/> optional createBalanceSubscription / seat upgrade
|
|
115
115
|
W-->>H: 200 { status: "ok" }
|
|
116
116
|
H-->>U: Redirect to url_success
|
|
117
117
|
U->>FE: Polls /api/billing/crypto/status?orderId=...
|
|
@@ -394,7 +394,7 @@ model CryptoPayment {
|
|
|
394
394
|
heleketuuid String @unique @map("heleket_uuid")
|
|
395
395
|
orderId String @unique @map("order_id")
|
|
396
396
|
amountUsd Float @map("amount_usd") // base amount user requested
|
|
397
|
-
tokenAmount Float @map("token_amount") // base + buffer
|
|
397
|
+
tokenAmount Float @map("token_amount") // ASSET amount (invoice token): base + buffer sent to Heleket — never plan units, never added to USD fields without a dated FX quote
|
|
398
398
|
status CryptoPaymentStatus @default(pending)
|
|
399
399
|
paymentUrl String? @map("payment_url")
|
|
400
400
|
currency String? // pricing currency, "USD"
|
|
@@ -1248,27 +1248,36 @@ Without a buffer, a $30 invoice frequently lands as `payment_amount_usd: 29.97`,
|
|
|
1248
1248
|
your balance is debited at $30 to provision a subscription, the user gets stuck on a
|
|
1249
1249
|
"insufficient balance" error 30 seconds after paying.
|
|
1250
1250
|
|
|
1251
|
-
**The fix**: add 1% on the invoice side
|
|
1252
|
-
|
|
1251
|
+
**The fix**: add 1% on the invoice side — and then WHAT HAPPENS TO THE EXCESS is a
|
|
1252
|
+
**required business policy, not a template default**. The template carries the enum and
|
|
1253
|
+
no preselected price:
|
|
1253
1254
|
|
|
1254
1255
|
```ts
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1256
|
+
// REQUIRED — the template does not choose for you. A missing policy BLOCKS
|
|
1257
|
+
// invoice creation and asks; it never silently invents a fee.
|
|
1258
|
+
type ExcessPolicy = 'refund_excess' | 'credit_excess' | 'buffer_fee';
|
|
1258
1259
|
```
|
|
1259
1260
|
|
|
1260
|
-
|
|
1261
|
+
One unit-safe example per variant (all Money in USD minor, per the dimensional
|
|
1262
|
+
contract — the buffer never mixes with Asset amounts):
|
|
1261
1263
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1264
|
+
- **`refund_excess`** — pay 3030¢ against a 3000¢ subscription → 30¢ goes BACK to the
|
|
1265
|
+
payer (an on-chain or balance refund row with its own audit line).
|
|
1266
|
+
- **`credit_excess`** — the 30¢ lands as 30¢ of BALANCE after provisioning; the UI
|
|
1267
|
+
breakdown says "credited to your balance", and the user keeps it.
|
|
1268
|
+
- **`buffer_fee`** — the 30¢ is retained as a disclosed fee: it appears as "buffer
|
|
1269
|
+
fee: 30¢" on the invoice BEFORE payment, never discovered afterwards.
|
|
1270
|
+
|
|
1271
|
+
```ts
|
|
1272
|
+
const bufferAmount = Math.ceil(amountUsd * 0.01 * 100) / 100; // 0.30 for $30
|
|
1273
|
+
const invoiceAmount = Math.round((amountUsd + bufferAmount) * 100) / 100; // 30.30
|
|
1274
|
+
// tokenAmount = invoiceAmount is the ASSET amount sent to Heleket either way;
|
|
1275
|
+
// the ExcessPolicy decides the LEDGER, not the invoice.
|
|
1266
1276
|
```
|
|
1267
1277
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
credit waterfall `paymentAmountUsd ?? tokenAmount ?? amountUsd` does this automatically.
|
|
1278
|
+
If Heleket reports `payment_amount_usd: 30.27` (a 0.1% loss), the waterfall values the
|
|
1279
|
+
payment at that figure — and the excess policy then applies to whatever remains above
|
|
1280
|
+
the base, in USD minor.
|
|
1272
1281
|
|
|
1273
1282
|
---
|
|
1274
1283
|
|
|
@@ -1301,16 +1310,26 @@ Heleket refunds are **merchant-initiated only** (user cannot self-refund). They
|
|
|
1301
1310
|
manually via the dashboard).
|
|
1302
1311
|
2. The user's destination wallet address (you have it as `fromAddress`).
|
|
1303
1312
|
|
|
1304
|
-
Webhook handling
|
|
1313
|
+
Webhook handling — refunds and holds are a SEPARATE ledger, not a branch of the
|
|
1314
|
+
paid path, and they are never dropped as a "duplicate" just because the invoice
|
|
1315
|
+
already reached `paid` (DV-05). A refund event arriving after `paid` is the
|
|
1316
|
+
NORMAL case, not a replay: the CAS that guards the paid→credit transition
|
|
1317
|
+
treats an already-final invoice as a duplicate, so the refund/hold statuses
|
|
1318
|
+
take their own path BEFORE that guard.
|
|
1305
1319
|
|
|
1306
1320
|
| Status | Action |
|
|
1307
1321
|
|--------|--------|
|
|
1308
|
-
| `refund_process` |
|
|
1309
|
-
| `refund_paid` |
|
|
1310
|
-
| `
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1322
|
+
| `refund_process` | Record a `refund_ledger` row `(paymentId, "refund_process")`, no balance change. |
|
|
1323
|
+
| `refund_paid` | Record `refund_ledger` `(paymentId, "refund_paid")` and, if the invoice was credited, debit the credited amount — **inside one transaction, keyed by `(paymentId, "refund_paid")`** so a redelivered refund webhook debits ONCE, never twice. A negative-balance clamp and its ops decision are DV-04's rule. |
|
|
1324
|
+
| `refund_hold` / `on_hold` | Record `refund_ledger` `(paymentId, status)`; pause downstream side effects, no debit yet. |
|
|
1325
|
+
| `refund_fail` | Record `refund_ledger` `(paymentId, "refund_fail")`, alert ops. |
|
|
1326
|
+
|
|
1327
|
+
The refund ledger is keyed by `(paymentId, status)` and its writes are
|
|
1328
|
+
idempotent: a repeated refund webhook finds the row and stops, so a
|
|
1329
|
+
paid→refund sequence adjusts the balance exactly once and a redelivery adds
|
|
1330
|
+
nothing. There is no chargeback mechanism in crypto — once `paid` is on-chain,
|
|
1331
|
+
the only path back is a refund initiated by you. Set up a financial alarm at,
|
|
1332
|
+
e.g., $500/month of refunds.
|
|
1314
1333
|
|
|
1315
1334
|
---
|
|
1316
1335
|
|
|
@@ -220,12 +220,40 @@ Observed 2026-08-24: a bot token was rotated, and for ten minutes the platform
|
|
|
220
220
|
reported the dyno `up` with a clean log while the API returned `401` to every
|
|
221
221
|
call and no user was served. Nothing in the system could tell the difference.
|
|
222
222
|
|
|
223
|
-
**
|
|
224
|
-
exists.
|
|
225
|
-
credential
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
223
|
+
**Three health states, because "up" is not one question.** *Liveness* — the
|
|
224
|
+
process exists and its loop runs. *Readiness* — it can do its job right now
|
|
225
|
+
(the credential works); a live process with a dead credential is **live but
|
|
226
|
+
NOT ready**, and a load balancer or supervisor reads readiness, not liveness.
|
|
227
|
+
*degraded_auth* — auth is failing, and its two sub-cases take OPPOSITE actions:
|
|
228
|
+
|
|
229
|
+
- **transient** (a rotated token, a network blip, a `401` a fresh connect
|
|
230
|
+
fixes): bounded retry with backoff, and a restart is legitimate because the
|
|
231
|
+
next start picks up the new credential — this is where "exit non-zero, a
|
|
232
|
+
crash loop is visible" is right.
|
|
233
|
+
- **terminal / revoked** (the session was killed server-side): a restart just
|
|
234
|
+
re-loops a login that can never succeed. **Stop work, go NOT ready, alert
|
|
235
|
+
with the account named, and wait for FRESH auth** — never a restart loop. A
|
|
236
|
+
bounded retry count separates the two: retries exhausted against the same
|
|
237
|
+
auth error is terminal, not "try harder".
|
|
238
|
+
|
|
239
|
+
**The recovery contract — one decision table both skills obey.** A supervisor
|
|
240
|
+
RESTART is applied only where the policy is `recoverable`; a `terminal` event
|
|
241
|
+
is never restarted, whichever skill saw it first:
|
|
242
|
+
|
|
243
|
+
| Event | Policy | Supervisor action | Readiness |
|
|
244
|
+
|---|---|---|---|
|
|
245
|
+
| transient auth failure (rotated token, network 401) | recoverable | restart / bounded retry | not ready → ready on success |
|
|
246
|
+
| retries exhausted on the same auth error | terminal | STOP, alert, wait for fresh auth | not ready until re-auth |
|
|
247
|
+
| revoked / killed session (MTProto or bearer) | terminal | STOP, alert, wait for fresh auth | not ready until re-auth |
|
|
248
|
+
|
|
249
|
+
The row a revoked session lands on is the SAME in both packs, so the identical
|
|
250
|
+
event cannot get opposite actions: error-tracking and `telegram-userbots` name
|
|
251
|
+
this one contract, and a supervisor that restarts a `terminal` policy is the
|
|
252
|
+
bug the table exists to forbid.
|
|
253
|
+
|
|
254
|
+
Sentry helps only if something raises; a connection that stopped working
|
|
255
|
+
without raising produces no event at all. Cron monitors (`sentry monitor`)
|
|
256
|
+
cover the shape where a job stops running.
|
|
229
257
|
|
|
230
258
|
## Degradation
|
|
231
259
|
|