@ssheleg/sheleg-dev 0.11.8 → 0.12.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 (24) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +4 -3
  3. package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
  4. package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +24 -8
  5. package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +57 -12
  6. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +24 -26
  7. package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +52 -0
  8. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +42 -23
  9. package/plugins/sheleg-dev/skills/error-tracking/SKILL.md +34 -6
  10. package/plugins/sheleg-dev/skills/error-tracking/references/scrubbing.md +65 -2
  11. package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +51 -23
  12. package/plugins/sheleg-dev/skills/google-auth/SKILL.md +13 -1
  13. package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +13 -3
  14. package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +150 -64
  15. package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +36 -19
  16. package/plugins/sheleg-dev/skills/google-signin/SKILL.md +55 -23
  17. package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +150 -49
  18. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +9 -7
  19. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +17 -1
  20. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +171 -5
  21. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +333 -198
  22. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +259 -35
  23. package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +149 -28
  24. package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +76 -5
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## v0.12.0 — the integration doctrine closes its audit, and a crypto reference gains its map
2
+
3
+ Sherlock external-v3 (43 findings) — the largest share in the family — each with
4
+ its own executable regression.
5
+
6
+ - Offline fault-injection corpora for every integration surface: card money,
7
+ crypto, ad tracking, error tracking, sign-in and page speed. Each case injects a
8
+ fault and judges it with a STATE or OUTPUT oracle rather than a provider stub
9
+ judging itself, and each counterexample fails before the fix and passes after.
10
+ - `test/negatives.py` stops reading an environment failure as a proven validation
11
+ gap: explicit stages, a resource preflight, a per-step timeout, and cleanup in a
12
+ `finally`. A full disk once printed forty "the guard does not actually fire"
13
+ findings; it now prints TEST_ERROR and zero guard-bypass findings.
14
+ - `crypto-payments/references/callback-route-hardening.md` gained a `## Contents`.
15
+ - CI now MEASURES the token budget with a real tokenizer instead of estimating it.
16
+
1
17
  ## v0.11.8 — a Contents entry pointing nowhere is depth the file does not have
2
18
 
3
19
  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.11.8",
3
+ "version": "0.12.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.11.8",
6
+ "version": "0.12.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 — roughly two thirds of the otherwise
79
- lost data. Blocking the script instead throws that away.
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
- gtag('config', 'G-XXXXXXXXXX', { allow_enhanced_conversions: true });
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 (required for pixel verification):**
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
- <noscript>
237
- <img height="1" width="1" style="display:none"
238
- src="https://www.facebook.com/tr?id=PIXEL_ID&ev=PageView&noscript=1"/>
239
- </noscript>
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
@@ -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, and by 2026 Google additionally
24
- expects a **certified CMP** from the Consent Management Platform programme —
25
- a hand-rolled banner that sets the signals correctly is no longer sufficient on
26
- its own for Google's ad products. Verify your CMP's certification status before
27
- treating this box as ticked. *(Checked 2026-08-06.)*
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 | No |
34
- | **Advanced** | Yes — tags load with denied defaults | Yes | Yes (recovers ~65-70% of lost data) |
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
- **Always prefer Advanced mode** — it allows Google to model conversions from users who deny
37
- consent without storing any cookies or identifying individuals.
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
- Advanced mode = `gtag('consent', 'default', { ... 'denied' ... })` then load tags normally.
40
- Basic mode = physically block `<script>` tags until consent granted.
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
- // Grant by default everywhere else (no banner needed)
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
- ```ts
183
- const { count } = await db.payment.updateMany({
184
- where: {
185
- invoiceId,
186
- status: { notIn: FINAL_STATUSES }, // ← the guard: a settled payment cannot re-settle
187
- },
188
- data: { status: mapped, paidAmount, txid, network, settledAt: new Date() },
189
- });
190
-
191
- if (count === 0) {
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
- What do you credit when the numbers disagree? Prefer the most authoritative,
269
- fall back in a fixed order, and log which one won:
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 credit = payment.paidAmountUsd // gateway's valuation of what arrived
273
- ?? payment.tokenAmount // what the plan says this purchase grants
274
- ?? payment.amountUsd; // the original intent — last resort
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 is unprovable.
276
+ audit row naming the source AND the quote used, or the first disputed balance
277
+ is unprovable.
280
278
 
281
279
  ---
282
280
 
@@ -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 = amount sent to Heleket
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, credit the **full invoice amount** (not the
1252
- base) to the user's balance.
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
- const bufferAmount = Math.ceil(amountUsd * 0.01 * 100) / 100; // 0.30 for $30
1256
- const invoiceAmount = Math.round((amountUsd + bufferAmount) * 100) / 100; // 30.30
1257
- const tokenAmount = invoiceAmount; // user gets the buffer back as balance
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
- Show the breakdown in the UI:
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
- Subscription: $30.00
1264
- Crypto buffer: +$0.30 (covers conversion losses, credited to your balance)
1265
- Invoice total: $30.30
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
- Why credit the full invoice (not just `paymentAmountUsd`)? Because if the user pays
1269
- exactly $30.30, they should keep $0.30 in balance after the subscription is provisioned.
1270
- If Heleket reports `payment_amount_usd: 30.27` (a 0.1% loss), use that instead — the
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` | Update DB row to `refund_process`, no balance change. |
1309
- | `refund_paid` | Update DB row to `refund_paid`. **Optionally** debit the user's balance manually if you previously credited them — the reference implementation leaves this to ops to avoid accidental negative balances. |
1310
- | `refund_fail` | Update DB row to `failed`, alert ops. |
1311
-
1312
- There is no chargeback mechanism in crypto — once `paid` is on-chain, the only path back
1313
- is a refund initiated by you. Set up a financial alarm at, e.g., $500/month of refunds.
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
- **Liveness must assert the thing the service exists to do**, not that a process
224
- exists. For anything holding a long-lived authenticated connection, check the
225
- credential periodically and exit non-zero when it fails — a crash loop is
226
- visible, a silent zombie is not. Sentry helps only if something raises; a
227
- connection that stopped working without raising produces no event at all. Cron
228
- monitors (`sentry monitor`) cover the shape where a job stops running.
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
 
@@ -89,13 +89,36 @@ covered by default instead of leaking until someone updates a list.
89
89
  ```python
90
90
  import re
91
91
 
92
- _URL_CREDENTIALS = re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.-]*://)(?P<user>[^:/@\s]+):(?P<pw>[^@/\s]+)@")
93
92
  _REDACTED = "<redacted>"
94
93
 
94
+ # userinfo: scheme://[user][:password]@host — the username is OPTIONAL, so
95
+ # `redis://:AUDIT_FAKE_PASSWORD@host` (empty user) is caught, not skipped; the
96
+ # password may be percent-encoded. Keep the (possibly empty) user and the host,
97
+ # redact only the password.
98
+ _URL_USERINFO = re.compile(
99
+ r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.-]*://)(?P<user>[^:/?#@\s]*):(?P<pw>[^@/?#\s]+)@")
100
+
101
+ # a secret in the PATH: a Telegram bot token is `/bot<id>:<secret>/…`, and the
102
+ # secret is in the path, not the userinfo — the userinfo regex never sees it.
103
+ _URL_PATH_BOT_TOKEN = re.compile(r"(?P<pre>/bot)(?P<tok>\d+:[A-Za-z0-9_-]+)")
104
+
105
+ # a credential in the QUERY STRING: ?access_token=… &token=… &api_key=… — the
106
+ # value, never the key, is redacted so the shape stays readable.
107
+ _URL_QUERY_SECRET = re.compile(
108
+ r"(?P<pre>[?&](?:access_token|auth_token|api_key|apikey|auth|token|key|"
109
+ r"password|passwd|secret|session|sig|signature)=)(?P<val>[^&#\s]+)",
110
+ re.IGNORECASE)
111
+
95
112
  def scrub_text(value):
96
113
  if not isinstance(value, str):
97
114
  return value
98
- return _URL_CREDENTIALS.sub(rf"\g<scheme>\g<user>:{_REDACTED}@", value)
115
+ # Every secret is replaced BEFORE the string can be logged: userinfo first,
116
+ # then the path token, then the query value. A safe URL (a lone user, a
117
+ # non-secret query key) matches none of them and keeps its useful shape.
118
+ value = _URL_USERINFO.sub(rf"\g<scheme>\g<user>:{_REDACTED}@", value)
119
+ value = _URL_PATH_BOT_TOKEN.sub(rf"\g<pre>{_REDACTED}", value)
120
+ value = _URL_QUERY_SECRET.sub(rf"\g<pre>{_REDACTED}", value)
121
+ return value
99
122
 
100
123
  def scrub_values(event, _hint=None):
101
124
  def walk(node):
@@ -116,6 +139,11 @@ Three properties that matter:
116
139
 
117
140
  - **Keep the user and host.** A fully redacted URL is useless for debugging.
118
141
  Redact the password only.
142
+ - **Three places a credential hides — userinfo, path, query.** `user:pw@` is
143
+ the obvious one, but a Telegram token sits in the PATH (`/bot<id>:<secret>/`)
144
+ and an access token sits in the QUERY (`?access_token=…`); the scrubber
145
+ covers all three, and the username in userinfo may be EMPTY
146
+ (`redis://:pw@host`) or the redis leak walks straight through.
119
147
  - **Scheme-agnostic.** `amqps://`, `redis://`, `mongodb+srv://` leak the same way.
120
148
  - **Never return `None`.** In `before_send` that discards the event. A bug in
121
149
  the scrubber then reads as "Sentry is quiet", which is indistinguishable from
@@ -153,6 +181,41 @@ default, the test tells you to stop carrying it yourself.
153
181
  same shape. A test that asserts a password is scrubbed, while carrying that
154
182
  password in the repository, has moved the leak rather than closed it.
155
183
 
184
+ ## Every OUTBOUND CHANNEL has its own hook — or it is not covered
185
+
186
+ `before_send` sees ERROR EVENTS and nothing else. Sentry ships several outbound
187
+ channels, each with its own hook, and a scrubber wired into one of them says
188
+ nothing about the rest — the table below is the claim, per channel, and a
189
+ channel without a hook is **UNSUPPORTED: stated, never promised away as a
190
+ universal scrub**:
191
+
192
+ | Channel | Hook | Status |
193
+ |---|---|---|
194
+ | error events | `before_send` (wired above) | covered |
195
+ | transactions | `before_send_transaction` — `before_send` does NOT see these | covered once wired |
196
+ | breadcrumbs | `before_breadcrumb` | covered once wired |
197
+ | logs (Sentry Logs) | `before_send_log` where the SDK version ships it | covered on that SDK; on an older SDK the channel is UNKNOWN — gate or disable it |
198
+ | attachments | **no scrub hook exists** — attachment bytes go out untouched | UNSUPPORTED: do not attach files that may carry secrets |
199
+
200
+ ```python
201
+ import sentry_sdk
202
+
203
+ sentry_sdk.init(
204
+ dsn=DSN,
205
+ before_send=scrub_values,
206
+ before_send_transaction=scrub_values,
207
+ before_breadcrumb=lambda crumb, _hint: scrub_values(crumb),
208
+ # before_send_log=scrub_values, # only where this SDK version has it;
209
+ # # absent => the logs channel is UNKNOWN
210
+ )
211
+ ```
212
+
213
+ **The fixture suite runs the SAME secret fixtures through EVERY channel the
214
+ table claims** — an event, a transaction, a breadcrumb — and a channel the
215
+ hooks cannot reach is reported UNKNOWN/UNSUPPORTED, never green. A green that
216
+ covers one channel while four others ship raw is the universal-scrub promise
217
+ this section exists to refuse.
218
+
156
219
  ## Re-measuring after an SDK upgrade
157
220
 
158
221
  Both facts on this page are version-dependent. After any `sentry-sdk` bump: