@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
|
@@ -41,21 +41,25 @@ secrets, no redirect URIs, no token storage for Google APIs.
|
|
|
41
41
|
Browser Google Your backend
|
|
42
42
|
─────── ────── ────────────
|
|
43
43
|
1. load gsi/client script
|
|
44
|
-
|
|
44
|
+
0. GET /api/auth/google/nonce ────────────────────────────► server issues nonce,
|
|
45
|
+
(pre-auth HttpOnly cookie set) stores it w/ TTL
|
|
46
|
+
2. google.accounts.id.initialize({client_id, callback, nonce: serverIssued})
|
|
45
47
|
3. renderButton(#container)
|
|
46
48
|
4. user clicks button ──────► account-chooser popup
|
|
47
49
|
(user picks account,
|
|
48
50
|
consents on first use)
|
|
49
51
|
5. callback receives ◄────── ID token (JWT, RS256-signed
|
|
50
52
|
{credential: "<jwt>"} by Google's private key)
|
|
51
|
-
6. POST /api/auth/google {credential
|
|
53
|
+
6. POST /api/auth/google {credential} ────────────────────► 7. verify ID token:
|
|
52
54
|
- signature vs Google JWKS
|
|
53
55
|
(https://www.googleapis.com/oauth2/v3/certs)
|
|
54
56
|
- aud == YOUR client_id
|
|
55
57
|
- iss == accounts.google.com
|
|
56
58
|
- exp not passed
|
|
57
59
|
- email_verified == true
|
|
58
|
-
- nonce
|
|
60
|
+
- nonce claim == server
|
|
61
|
+
expectation (popped:
|
|
62
|
+
one-time consume)
|
|
59
63
|
8. find-or-create user,
|
|
60
64
|
link accounts
|
|
61
65
|
9. issue YOUR OWN session
|
|
@@ -81,7 +85,7 @@ Payload claims you care about:
|
|
|
81
85
|
"email_verified": true,
|
|
82
86
|
"name": "Ada Lovelace",
|
|
83
87
|
"picture": "https://lh3.googleusercontent.com/…",
|
|
84
|
-
"nonce": "d9b2d63d-…", //
|
|
88
|
+
"nonce": "d9b2d63d-…", // the SERVER-issued value passed to initialize()
|
|
85
89
|
"iat": 1719410000,
|
|
86
90
|
"exp": 1719413600 // ~1 hour lifetime
|
|
87
91
|
}
|
|
@@ -107,7 +111,7 @@ Payload claims you care about:
|
|
|
107
111
|
// app.js (essentials)
|
|
108
112
|
var _googleNonce = '';
|
|
109
113
|
|
|
110
|
-
function _renderGoogleButton() {
|
|
114
|
+
async function _renderGoogleButton() {
|
|
111
115
|
var clientId = _getGoogleClientId(); // read from the <meta> tag
|
|
112
116
|
if (!clientId) return; // Google auth not configured → hide, degrade honestly
|
|
113
117
|
if (typeof google === 'undefined' || !google.accounts) {
|
|
@@ -115,7 +119,10 @@ function _renderGoogleButton() {
|
|
|
115
119
|
if (_googleRenderRetries++ < 20) setTimeout(_renderGoogleButton, 150);
|
|
116
120
|
return;
|
|
117
121
|
}
|
|
118
|
-
|
|
122
|
+
// SERVER-issued nonce: the backend stores it (TTL, pre-auth HttpOnly cookie)
|
|
123
|
+
// and will pop it on the callback — the client never invents the value.
|
|
124
|
+
var nr = await fetch('/api/auth/google/nonce');
|
|
125
|
+
_googleNonce = (await nr.json()).nonce;
|
|
119
126
|
|
|
120
127
|
google.accounts.id.initialize({
|
|
121
128
|
client_id: clientId,
|
|
@@ -135,7 +142,7 @@ async function handleGoogleCredential(response) {
|
|
|
135
142
|
var resp = await fetch('/api/auth/google', {
|
|
136
143
|
method: 'POST',
|
|
137
144
|
headers: { 'Content-Type': 'application/json' },
|
|
138
|
-
body: JSON.stringify({ credential: response.credential
|
|
145
|
+
body: JSON.stringify({ credential: response.credential }),
|
|
139
146
|
});
|
|
140
147
|
var data = await resp.json();
|
|
141
148
|
if (!resp.ok) { showError(data.detail); return; }
|
|
@@ -185,14 +192,21 @@ Every Google login resolves to exactly one of three cases:
|
|
|
185
192
|
|
|
186
193
|
1. **Returning Google user** — a record with this `google_id` (`sub`) exists
|
|
187
194
|
→ issue session. Refresh `name`/`picture` if they changed.
|
|
188
|
-
2. **Existing
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
2. **Existing account, no `google_id`** — `email_verified` is NOT authority to
|
|
196
|
+
attach Google to it. It means Google verified the inbox once; it does not
|
|
197
|
+
mean this person owns the LOCAL account, and for a third-party address it
|
|
198
|
+
does not even mean they still own the inbox. Default: link only after a
|
|
199
|
+
**fresh re-auth of the existing local account** (§4.3). Auto-link without
|
|
200
|
+
re-auth is permitted only when `google_authoritative(payload)` — the address
|
|
201
|
+
is `@gmail.com` or the token carries an `hd` Workspace claim — AND the
|
|
202
|
+
existing record's own email ownership was proven; for any other address run
|
|
203
|
+
an **independent inbox challenge** first. If the existing account is a
|
|
204
|
+
password account whose email was **never verified**, route to **safe
|
|
205
|
+
account recovery** (§4.3), never to the existing password.
|
|
206
|
+
3. **Brand-new user** — create the account, empty password hash. Mark
|
|
207
|
+
`email_verified=True` only when `google_authoritative(payload)`; for a
|
|
208
|
+
third-party address (non-Gmail, no `hd`) create it UNVERIFIED and challenge
|
|
209
|
+
the inbox before anything trusts the email → issue session.
|
|
196
210
|
|
|
197
211
|
Then issue your own session. Prowl issues an HS256 app JWT (72 h, `ver` claim
|
|
198
212
|
for global revocation) delivered as an **HttpOnly, Secure, SameSite=Strict
|
|
@@ -261,11 +275,17 @@ npm i google-auth-library # Node
|
|
|
261
275
|
|
|
262
276
|
### 4.2 Nonce — replay defense
|
|
263
277
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
278
|
+
**The server issues the nonce; the client never invents one.** A
|
|
279
|
+
client-generated nonce POSTed back beside the credential proves nothing — the
|
|
280
|
+
"expected" value would be read out of the very JWT it is meant to check, so a
|
|
281
|
+
stolen token replays with its own nonce forever. Fetch the nonce from
|
|
282
|
+
`GET /api/auth/google/nonce` (the server stores it with a TTL, bound to a
|
|
283
|
+
pre-auth HttpOnly session cookie), pass it to
|
|
284
|
+
`google.accounts.id.initialize({nonce})`, and on the callback the server POPs
|
|
285
|
+
the expectation (one-time consume) and requires the token's `nonce` claim to
|
|
286
|
+
equal it exactly. Missing claim, missing expectation, expiry and re-use all
|
|
287
|
+
reject. First sign-in passes; a replay from a new session meets a different
|
|
288
|
+
expectation; a replay in the same session meets a consumed one.
|
|
269
289
|
|
|
270
290
|
### 4.3 Account pre-hijacking guard (P3-01 in Prowl)
|
|
271
291
|
|
|
@@ -274,10 +294,31 @@ victim ever visits. Later the victim clicks "Sign in with Google". Naïve
|
|
|
274
294
|
auto-linking attaches victim's Google to the **attacker's** record — attacker
|
|
275
295
|
keeps password access to the merged account (its data and wallet).
|
|
276
296
|
|
|
277
|
-
Defense
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
297
|
+
Defense: `email_verified` is not proof of ownership OF THE LOCAL ACCOUNT, and
|
|
298
|
+
Google is only authoritative for the *address* when it is Gmail or carries an
|
|
299
|
+
`hd` Workspace claim — for a third-party email, ownership can have changed
|
|
300
|
+
since Google verified it (Google's own guidance excludes non-Gmail without
|
|
301
|
+
`hd`). So:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
def google_authoritative(payload):
|
|
305
|
+
email = (payload.get("email") or "").lower()
|
|
306
|
+
return bool(payload.get("email_verified")
|
|
307
|
+
and (email.endswith("@gmail.com") or payload.get("hd")))
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
- **Default** — link only after a **fresh re-auth of the existing local
|
|
311
|
+
account** (its password or existing factor). Proving you hold the account is
|
|
312
|
+
the only thing that authorizes attaching a new identity to it.
|
|
313
|
+
- **Auto-link without re-auth** — permitted only when
|
|
314
|
+
`google_authoritative(payload)` AND the existing record's email ownership was
|
|
315
|
+
already proven. For any other address, run an **independent challenge** (a
|
|
316
|
+
verification link to that inbox) first.
|
|
317
|
+
- **Unverified pre-registration** — when the existing password account's email
|
|
318
|
+
was never verified, DO NOT tell the victim to "sign in with the password":
|
|
319
|
+
that password may be the attacker's. Route them into a **safe
|
|
320
|
+
account-recovery flow** (reset via a fresh inbox challenge), which takes the
|
|
321
|
+
account away from whoever pre-registered it rather than handing it over.
|
|
281
322
|
|
|
282
323
|
### 4.4 Login-CSRF (P3-03 in Prowl)
|
|
283
324
|
|
|
@@ -287,12 +328,22 @@ attacker's account (victim then unknowingly feeds data/payments into it).
|
|
|
287
328
|
|
|
288
329
|
Defense (`web/server.py:1657-1686`), covering both possible delivery flows:
|
|
289
330
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
331
|
+
The two flows are SEPARATE, explicitly typed paths chosen by Content-Type. A
|
|
332
|
+
handler that claims form support but binds only a JSON body never runs the form
|
|
333
|
+
contract — the CSRF check for the form flow (`g_csrf_token`) is on a request
|
|
334
|
+
shape that flow never sends.
|
|
335
|
+
|
|
336
|
+
- **Form-POST flow** (`login_uri` auto-POST, `application/x-www-form-urlencoded`):
|
|
337
|
+
GIS double-submits `g_csrf_token` in the FORM BODY and the cookie — require
|
|
338
|
+
BOTH present and equal, constant-time (`hmac.compare_digest`). Reject if
|
|
339
|
+
either is missing.
|
|
340
|
+
- **JS-fetch flow** (`application/json`): no `g_csrf_token`, so require a
|
|
341
|
+
trusted same-origin signal — `Sec-Fetch-Site: same-origin`, OR (only when
|
|
342
|
+
that header is ABSENT) an exact `Origin` in an allowlist. A **missing
|
|
343
|
+
`Sec-Fetch-Site` is NOT assumed same-origin**; `cross-site` and `none` are
|
|
344
|
+
rejected; with neither a trusted metadata value nor an allowed Origin the
|
|
345
|
+
request **fails closed**. These headers are browser-set and unforgeable by
|
|
346
|
+
page script. The CSRF decision runs BEFORE token verification.
|
|
296
347
|
|
|
297
348
|
### 4.5 Session hygiene
|
|
298
349
|
|
|
@@ -346,8 +397,8 @@ Defense (`web/server.py:1657-1686`), covering both possible delivery flows:
|
|
|
346
397
|
|
|
347
398
|
```python
|
|
348
399
|
# --- backend ---
|
|
349
|
-
import os, hmac
|
|
350
|
-
from fastapi import FastAPI, HTTPException, Request, Response
|
|
400
|
+
import os, hmac, secrets, time
|
|
401
|
+
from fastapi import FastAPI, Form, HTTPException, Request, Response
|
|
351
402
|
from pydantic import BaseModel
|
|
352
403
|
from google.oauth2 import id_token
|
|
353
404
|
from google.auth.transport import requests as google_requests
|
|
@@ -356,29 +407,79 @@ GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
|
|
|
356
407
|
_transport = google_requests.Request()
|
|
357
408
|
app = FastAPI()
|
|
358
409
|
|
|
410
|
+
NONCE_TTL = 600
|
|
411
|
+
_nonce_store: dict[str, tuple[str, float]] = {} # sid -> (nonce, expires); use Redis in prod
|
|
412
|
+
|
|
413
|
+
ALLOWED_ORIGINS = {"https://app.example.com"} # your own origin(s)
|
|
414
|
+
|
|
415
|
+
|
|
359
416
|
class GoogleAuthRequest(BaseModel):
|
|
360
417
|
credential: str
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
418
|
+
g_csrf_token: str | None = None # no nonce in the body — the server holds it
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _csrf_ok_form(cookie_token, form_token):
|
|
422
|
+
# The form flow's whole authorization: both present and equal, constant-time.
|
|
423
|
+
if not form_token or not cookie_token:
|
|
424
|
+
return False
|
|
425
|
+
return hmac.compare_digest(cookie_token, form_token)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _csrf_ok_json(sec_fetch_site, origin):
|
|
429
|
+
# Trusted metadata first; Origin allowlist ONLY as an explicit fallback when
|
|
430
|
+
# the header is absent. A missing Sec-Fetch-Site is not same-origin, and
|
|
431
|
+
# `none`/`cross-site` are rejected — fail closed when neither proves origin.
|
|
432
|
+
if sec_fetch_site == "same-origin":
|
|
433
|
+
return True
|
|
434
|
+
if sec_fetch_site in ("cross-site", "same-site", "none"):
|
|
435
|
+
return False
|
|
436
|
+
if sec_fetch_site is None: # header absent → exact Origin, allowlisted
|
|
437
|
+
return bool(origin) and origin in ALLOWED_ORIGINS
|
|
438
|
+
return False
|
|
439
|
+
|
|
440
|
+
@app.get("/api/auth/google/nonce")
|
|
441
|
+
async def google_nonce(req: Request, response: Response):
|
|
442
|
+
sid = req.cookies.get("preauth") or secrets.token_urlsafe(32)
|
|
443
|
+
nonce = secrets.token_urlsafe(32)
|
|
444
|
+
_nonce_store[sid] = (nonce, time.time() + NONCE_TTL)
|
|
445
|
+
response.set_cookie("preauth", sid, httponly=True, secure=True,
|
|
446
|
+
samesite="lax", max_age=NONCE_TTL, path="/")
|
|
447
|
+
return {"nonce": nonce}
|
|
448
|
+
|
|
449
|
+
# Two explicitly typed routes. Content-Type picks the flow; each runs ONLY its
|
|
450
|
+
# own CSRF contract; both fail closed; the check is before any token call.
|
|
451
|
+
|
|
452
|
+
@app.post("/api/auth/google/form") # application/x-www-form-urlencoded
|
|
453
|
+
async def google_auth_form(req: Request, response: Response,
|
|
454
|
+
credential: str = Form(...),
|
|
455
|
+
g_csrf_token: str = Form(None)):
|
|
456
|
+
if not _csrf_ok_form(req.cookies.get("g_csrf_token"), g_csrf_token):
|
|
457
|
+
raise HTTPException(403, "CSRF: g_csrf_token missing or mismatched")
|
|
458
|
+
return await _finish_google_auth(req, response, credential)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@app.post("/api/auth/google") # application/json (JS fetch)
|
|
365
462
|
async def google_auth(req: Request, data: GoogleAuthRequest, response: Response):
|
|
366
|
-
|
|
367
|
-
if
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
raise HTTPException(403, "CSRF token mismatch")
|
|
371
|
-
elif req.headers.get("sec-fetch-site", "same-origin") not in ("same-origin", "none"):
|
|
372
|
-
raise HTTPException(403, "Cross-site request rejected")
|
|
463
|
+
sec = req.headers.get("sec-fetch-site") # None when the header is absent
|
|
464
|
+
if not _csrf_ok_json(sec, req.headers.get("origin")):
|
|
465
|
+
raise HTTPException(403, "CSRF: request origin not proven (fail closed)")
|
|
466
|
+
return await _finish_google_auth(req, response, data.credential)
|
|
373
467
|
|
|
468
|
+
|
|
469
|
+
async def _finish_google_auth(req: Request, response: Response, credential: str):
|
|
374
470
|
try:
|
|
375
|
-
payload = id_token.verify_oauth2_token(
|
|
471
|
+
payload = id_token.verify_oauth2_token(credential, _transport, GOOGLE_CLIENT_ID)
|
|
376
472
|
except Exception as exc:
|
|
377
473
|
raise HTTPException(401, f"Invalid Google token: {exc}")
|
|
378
474
|
if not payload.get("email_verified"):
|
|
379
475
|
raise HTTPException(401, "Google account email is not verified")
|
|
380
|
-
|
|
381
|
-
|
|
476
|
+
# One-time consume BEFORE any account work: pop() removes the expectation
|
|
477
|
+
# in the same step, so a replayed callback finds nothing. Mandatory — a
|
|
478
|
+
# missing claim or a missing expectation rejects; the body carries no nonce.
|
|
479
|
+
sid = req.cookies.get("preauth") or ""
|
|
480
|
+
expected, expires = _nonce_store.pop(sid, (None, 0.0))
|
|
481
|
+
if not expected or time.time() > expires or payload.get("nonce") != expected:
|
|
482
|
+
raise HTTPException(401, "Nonce missing, expired, replayed or not server-issued")
|
|
382
483
|
|
|
383
484
|
user = await find_or_create_user( # your 3-way linking here (§2.4, §4.3)
|
|
384
485
|
google_id=payload["sub"], email=payload["email"].lower().strip(),
|
|
@@ -395,8 +496,8 @@ async def google_auth(req: Request, data: GoogleAuthRequest, response: Response)
|
|
|
395
496
|
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
|
396
497
|
<div id="gbtn"></div>
|
|
397
498
|
<script>
|
|
398
|
-
|
|
399
|
-
|
|
499
|
+
window.onload = async () => {
|
|
500
|
+
const NONCE = (await (await fetch("/api/auth/google/nonce")).json()).nonce;
|
|
400
501
|
google.accounts.id.initialize({
|
|
401
502
|
client_id: "YOUR_CLIENT_ID.apps.googleusercontent.com",
|
|
402
503
|
nonce: NONCE,
|
|
@@ -404,7 +505,7 @@ async def google_auth(req: Request, data: GoogleAuthRequest, response: Response)
|
|
|
404
505
|
const r = await fetch("/api/auth/google", {
|
|
405
506
|
method: "POST",
|
|
406
507
|
headers: { "Content-Type": "application/json" },
|
|
407
|
-
body: JSON.stringify({ credential
|
|
508
|
+
body: JSON.stringify({ credential }),
|
|
408
509
|
});
|
|
409
510
|
if (r.ok) location.href = "/app";
|
|
410
511
|
},
|
|
@@ -6,14 +6,14 @@ description: >-
|
|
|
6
6
|
coupon offered at the cancel step, the portal, and the webhook that
|
|
7
7
|
turns a payment into an entitlement in your database. Covers Stripe's
|
|
8
8
|
agent toolchain, the pinned API version and SDK retries, price
|
|
9
|
-
resolution
|
|
9
|
+
resolution, claim-first webhook idempotency, what invoice
|
|
10
10
|
billing_reason decides, cumulative refunds, the cancellation field
|
|
11
11
|
flexible billing_mode moved, retention eligibility Stripe cannot
|
|
12
12
|
express, and write ordering with compensating reverts. Triggers - "add
|
|
13
13
|
Stripe", "Stripe checkout", "subscription billing", "webhook signature",
|
|
14
14
|
"invoice.paid", "proration", "refund", "cancel subscription", "retention
|
|
15
15
|
coupon", "подключить Stripe", "оплата подпиской", "вебхук Stripe",
|
|
16
|
-
"скидка при отмене", "биллинг". Not for choosing between Stripe products
|
|
16
|
+
"скидка при отмене", "биллинг подписок". Not for choosing between Stripe products
|
|
17
17
|
(stripe-best-practices) or reading Stripe docs (stripe-docs).
|
|
18
18
|
license: MIT
|
|
19
19
|
---
|
|
@@ -198,7 +198,8 @@ export async function POST(request: Request) {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
const claim = await claimEvent(event.id); // INSERT on a primary key
|
|
201
|
-
if (claim === "
|
|
201
|
+
if (claim === "completed") return json({ received: true, duplicate: true });
|
|
202
|
+
if (claim === "in_flight") return json({ error: "in flight" }, 500); // retry later
|
|
202
203
|
|
|
203
204
|
try {
|
|
204
205
|
await handle(event);
|
|
@@ -215,16 +216,17 @@ export async function POST(request: Request) {
|
|
|
215
216
|
- **Exempt this path — and only this path — from CSRF and session auth**, by
|
|
216
217
|
exact match: a prefix match over `/api/billing` exempts checkout too, which is
|
|
217
218
|
where the money is.
|
|
218
|
-
- **Claim before working
|
|
219
|
-
|
|
220
|
-
processing
|
|
221
|
-
|
|
219
|
+
- **Claim before working — a claim is a receipt, not completion.** `SELECT`
|
|
220
|
+
then `INSERT` is a race; two deliveries 40 ms apart both credit. The row
|
|
221
|
+
stays `processing` until the grant's transaction marks it `completed` —
|
|
222
|
+
states, expiry, takeover and release:
|
|
223
|
+
[`references/webhook-events.md`](references/webhook-events.md).
|
|
222
224
|
- **Answer honestly.** 200 handled or duplicate, 400 bad signature, 5xx try
|
|
223
225
|
again, 200 for types you do not handle. Never 200 on failure to stop retries —
|
|
224
226
|
that discards a payment quietly.
|
|
225
227
|
- **Order inside a handler:** fallible external calls first, then one
|
|
226
|
-
transaction
|
|
227
|
-
|
|
228
|
+
transaction — entitlement, dedup marker, completion, outbox rows — then side
|
|
229
|
+
effects drain from the outbox, each under its own consumer key.
|
|
228
230
|
|
|
229
231
|
Per-event detail: [`references/webhook-events.md`](references/webhook-events.md).
|
|
230
232
|
|
|
@@ -377,6 +379,21 @@ renewal and proration. The one thing to know before you get there: the signing
|
|
|
377
379
|
secret `stripe listen` prints is **not** the dashboard's, and using the wrong one
|
|
378
380
|
fails verification in a way that reads like a key problem.
|
|
379
381
|
|
|
382
|
+
## Degradation
|
|
383
|
+
|
|
384
|
+
- **Not Claude Code** (Cursor, Codex, the skills CLI, the API container): the
|
|
385
|
+
`PreToolUse` money gate this pack ships does not exist there — nothing refuses a
|
|
386
|
+
refund, a payout, a dispute close or a live `sk_live_…` key on your behalf. Every
|
|
387
|
+
rule below still holds; it is advice again rather than a precondition, so the
|
|
388
|
+
authorisation step becomes a human one. Say which you are on before acting on money.
|
|
389
|
+
- **Installed by copy rather than as a plugin**: same loss, same remedy — a plain
|
|
390
|
+
`~/.claude/skills/` copy carries the doctrine and not `hooks/money-gate.js`. The
|
|
391
|
+
README names the categories and how one is signed off; without the hook, that
|
|
392
|
+
sign-off is a sentence somebody has to actually say.
|
|
393
|
+
- **No Stripe CLI, no network, or test keys only**: webhook signature verification and
|
|
394
|
+
the local replay loop are the parts that need the binary. State which is missing
|
|
395
|
+
once, do the rest, and record what could not be verified rather than implying it was.
|
|
396
|
+
|
|
380
397
|
## Before you ship
|
|
381
398
|
|
|
382
399
|
Not a second checklist — every rule below already has a section above, and a rule
|
|
@@ -62,7 +62,17 @@ Run `node assert-money-invariants.mjs` and each line names the invariant it hold
|
|
|
62
62
|
| `proration-invoice-grants-nothing` | cycle + proration | gives a month of product for a $40 proration invoice |
|
|
63
63
|
| `sequential-redelivery-grants-once` | cycle + redelivery | answers `received` twice and sends the renewal notice and the server-side conversion twice |
|
|
64
64
|
| `concurrent-redelivery-grants-once` | cycle + redelivery, in flight together | both deliveries pass the marker's `SELECT` and both credit |
|
|
65
|
+
| `crash-after-receipt-is-retryable` | cycle (worker dies) + redelivery | tells the retry "duplicate" about work that never finished — the payment is swallowed forever |
|
|
66
|
+
| `in-flight-claim-answers-retry-later` | cycle (worker dies) + redelivery | answers `duplicate` to a retry while the first worker may still be mid-grant |
|
|
67
|
+
| `completion-is-recorded-with-the-grant` | cycle | leaves the claim row saying `processing` after answering done — every later retry reads it as a corpse |
|
|
68
|
+
| `duplicate-of-completed-never-regrants` | cycle + redelivery, aged past the claim expiry | takes the completed row over as if the worker had died, and grants again |
|
|
69
|
+
| `transaction-rolls-back-whole` | cycle (crash inside the transaction) | leaves the grant applied with no completion — half a payment no retry can see |
|
|
70
|
+
| `crash-before-commit-applies-nothing` | cycle (crash) + redelivery | the retry double-applies or never applies what the crash interrupted |
|
|
71
|
+
| `committed-retry-sends-once` | cycle, outbox redelivered | sends "your renewal" twice and counts the revenue twice |
|
|
65
72
|
| `sequential-redelivery-grants-once-by-count-alone` | cycle + redelivery | *nothing* — kept because that is the finding; see below |
|
|
73
|
+
| `concurrent-entry-points-grant-once` | cycle, webhook + reconciler in flight together | both pass the marker's read and one period is granted twice |
|
|
74
|
+
| `serialization-conflict-does-not-lose-the-renewal` | cycle, one injected conflict | drops the conflicted transaction and answers 200 — the renewal silently never lands |
|
|
75
|
+
| `exhausted-retries-answer-5xx-and-the-redelivery-lands` | cycle + redelivery, conflicts past the bound | swallows the conflict instead of answering 5xx, so nothing ever retries |
|
|
66
76
|
| `reconciliation-does-not-regrant` | cycle | the nightly repair grants a period the webhook already granted |
|
|
67
77
|
| `out-of-order-pair-does-not-rewind-state` | February then January | stores the older period, so the renewal date points at a period that ended |
|
|
68
78
|
| `refund-total-is-cumulative` | both refunds | claws back $130 against a $90 charge |
|
|
@@ -80,7 +90,13 @@ a line a generated handler routinely omits:
|
|
|
80
90
|
|
|
81
91
|
| Rule | The code it removes | The generated handler that ships without it |
|
|
82
92
|
|---|---|---|
|
|
83
|
-
| `claim` |
|
|
93
|
+
| `claim` | the whole claim block at the top of `deliver` | reads the processed table, then writes it — two deliveries 40 ms apart both pass the read |
|
|
94
|
+
| `claim-completion` | `store.completeEvent(event.id)` after the grant commits | leaves every row at `processing`, so a retry past the claim expiry reprocesses a completed event |
|
|
95
|
+
| `claim-expiry` | the takeover of a `processing` row older than `CLAIM_TTL_MS` | answers "in flight" forever for a worker that died — received quietly read as completed |
|
|
96
|
+
| `atomic-application` | the snapshot/restore around the business application | applies the grant and loses the completion when a crash lands mid-way |
|
|
97
|
+
| `outbox-consumer-key` | the consumer's own dedup across redelivered rows | sends every redelivered outbox row again |
|
|
98
|
+
| `grant-key-atomic` | the keyed INSERT claim on the period grant | falls back to read-then-mark — two concurrent grants of one period both pass the read |
|
|
99
|
+
| `tx-retry-bounded` | the bounded serialization-retry loop | a conflicted transaction is dropped and the route answers 200 |
|
|
84
100
|
| `billing-reason` | `if (invoice.billing_reason !== 'subscription_cycle') return` | handles `invoice.paid` as "a payment arrived" |
|
|
85
101
|
| `grant-marker` | `if (granted.has(period.start)) return` | grants whenever an event says paid, including from the reconciliation job |
|
|
86
102
|
| `ordering` | `if (period.start > mirror.periodStart)` around the mirror write | writes whatever arrived last |
|
|
@@ -28,7 +28,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
import assert from 'node:assert/strict';
|
|
30
30
|
import {
|
|
31
|
-
createStore, createHandler, subscriptionIdOf, RULES, CREDITS_PER_PERIOD,
|
|
31
|
+
createStore, createHandler, subscriptionIdOf, RULES, CREDITS_PER_PERIOD, CLAIM_TTL_MS,
|
|
32
|
+
TX_RETRIES,
|
|
32
33
|
} from './reference-handler.mjs';
|
|
33
34
|
|
|
34
35
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -117,7 +118,9 @@ export const INVARIANTS = [
|
|
|
117
118
|
id: 'sequential-redelivery-grants-once',
|
|
118
119
|
fixtures: [CYCLE_JAN, CYCLE_JAN_REDELIVERY],
|
|
119
120
|
states: 'the same evt_ delivered twice grants once AND runs its side effects once',
|
|
120
|
-
|
|
121
|
+
// `claim-completion` is here because "duplicate" is an answer about a COMPLETED row:
|
|
122
|
+
// a handler that never marks completion answers the redelivery in-flight instead.
|
|
123
|
+
breaks: ['claim', 'claim-completion', 'grant-on-renewal'],
|
|
121
124
|
async run({ store, handler, assert }) {
|
|
122
125
|
const first = await handler.deliver(fixture(CYCLE_JAN));
|
|
123
126
|
const second = await handler.deliver(fixture(CYCLE_JAN_REDELIVERY));
|
|
@@ -143,13 +146,176 @@ export const INVARIANTS = [
|
|
|
143
146
|
handler.deliver(fixture(CYCLE_JAN)),
|
|
144
147
|
handler.deliver(fixture(CYCLE_JAN_REDELIVERY)),
|
|
145
148
|
]);
|
|
146
|
-
const bodies = [a.body, b.body];
|
|
147
149
|
assert.equal(store.grants.length, 1, 'both deliveries granted');
|
|
148
150
|
assert.equal(store.credits.get('usr_PLACEHOLDER_alice'), CREDITS_PER_PERIOD,
|
|
149
151
|
'the user was credited twice for one renewal');
|
|
150
152
|
assert.equal(store.conversions.length, 1, 'the conversion fired twice');
|
|
151
|
-
|
|
152
|
-
|
|
153
|
+
// "duplicate" is an answer about a COMPLETED row. Mid-flight the loser answers 5xx
|
|
154
|
+
// so its own retry keeps the event alive if the winner dies before completing.
|
|
155
|
+
const winners = [a, b].filter((r) => r.status === 200 && r.body.duplicate !== true);
|
|
156
|
+
const loser = [a, b].find((r) => !winners.includes(r));
|
|
157
|
+
assert.ok(winners.length === 1 && loser
|
|
158
|
+
&& ((loser.status === 200 && loser.body.duplicate === true)
|
|
159
|
+
|| (loser.status === 500 && loser.body.error === 'in flight')),
|
|
160
|
+
'exactly one delivery processes; the other answers duplicate or in-flight, never grants');
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 'crash-after-receipt-is-retryable',
|
|
165
|
+
fixtures: [CYCLE_JAN, CYCLE_JAN_REDELIVERY],
|
|
166
|
+
states: 'a worker that dies after claiming leaves a claim a later retry takes over — receipt is not completion',
|
|
167
|
+
breaks: ['claim-expiry', 'grant-on-renewal'],
|
|
168
|
+
async run({ store, handler, assert }) {
|
|
169
|
+
const dead = await handler.deliver(fixture(CYCLE_JAN), { crashAfterClaim: true });
|
|
170
|
+
assert.unmutated.equal(dead.status, 0, 'the crash simulation must not answer'); // the worker is gone
|
|
171
|
+
store.advanceClock(CLAIM_TTL_MS + 1);
|
|
172
|
+
const late = await handler.deliver(fixture(CYCLE_JAN_REDELIVERY));
|
|
173
|
+
assert.deepEqual(late.body, { received: true },
|
|
174
|
+
'the retry after the claim expiry was not let in — the payment is swallowed forever');
|
|
175
|
+
assert.equal(store.grants.length, 1, 'the recovered event did not grant');
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: 'in-flight-claim-answers-retry-later',
|
|
180
|
+
fixtures: [CYCLE_JAN, CYCLE_JAN_REDELIVERY],
|
|
181
|
+
states: 'a retry inside the claim window is told to come back — "duplicate" is an answer about a completed row',
|
|
182
|
+
breaks: ['claim'],
|
|
183
|
+
async run({ store, handler, assert }) {
|
|
184
|
+
await handler.deliver(fixture(CYCLE_JAN), { crashAfterClaim: true });
|
|
185
|
+
const early = await handler.deliver(fixture(CYCLE_JAN_REDELIVERY));
|
|
186
|
+
assert.ok(early.status === 500 && store.grants.length === 0,
|
|
187
|
+
'a retry inside the claim window must wait out a possibly live worker, not process or be told duplicate');
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'completion-is-recorded-with-the-grant',
|
|
192
|
+
fixtures: [CYCLE_JAN],
|
|
193
|
+
states: 'the claim row leaves processing the moment the grant commits — received and completed are different states',
|
|
194
|
+
breaks: ['claim-completion'],
|
|
195
|
+
async run({ store, handler, assert }) {
|
|
196
|
+
const event = fixture(CYCLE_JAN);
|
|
197
|
+
await handler.deliver(event);
|
|
198
|
+
const row = store.processedEvents.get(event.id);
|
|
199
|
+
// Conditional on the row existing so this isolates the completion mark: with no
|
|
200
|
+
// claim at all there is no row to leave in the wrong state.
|
|
201
|
+
assert.ok(!row || row.state === 'completed',
|
|
202
|
+
'the delivery answered done and its claim row still says processing — a crash-shaped lie');
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: 'duplicate-of-completed-never-regrants',
|
|
207
|
+
fixtures: [CYCLE_JAN, CYCLE_JAN_REDELIVERY],
|
|
208
|
+
states: 'a completed row stays completed: a retry arriving after the claim expiry answers duplicate and grants nothing',
|
|
209
|
+
breaks: ['claim', 'claim-completion', 'grant-on-renewal'],
|
|
210
|
+
async run({ store, handler, assert }) {
|
|
211
|
+
const first = await handler.deliver(fixture(CYCLE_JAN));
|
|
212
|
+
assert.unmutated.deepEqual(first.body, { received: true });
|
|
213
|
+
store.advanceClock(CLAIM_TTL_MS + 1);
|
|
214
|
+
const late = await handler.deliver(fixture(CYCLE_JAN_REDELIVERY));
|
|
215
|
+
assert.deepEqual(late.body, { received: true, duplicate: true },
|
|
216
|
+
'a completed event aged past the claim expiry was reprocessed — completion was never recorded');
|
|
217
|
+
assert.equal(store.grants.length, 1, 'a completed event was granted again');
|
|
218
|
+
assert.equal(store.notifications.length, 1, '"your renewal" was sent twice');
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: 'transaction-rolls-back-whole',
|
|
223
|
+
fixtures: [CYCLE_JAN],
|
|
224
|
+
states: 'a crash inside the transaction leaves nothing: no grant, no marker, no outbox row, no completion',
|
|
225
|
+
breaks: ['atomic-application'],
|
|
226
|
+
async run({ store, handler, assert }) {
|
|
227
|
+
const dead = await handler.deliver(fixture(CYCLE_JAN), { crashBeforeCommit: true });
|
|
228
|
+
assert.unmutated.equal(dead.status, 0, 'the crash simulation must not answer');
|
|
229
|
+
assert.ok(store.grants.length === 0 && store.grantedPeriods.size === 0
|
|
230
|
+
&& store.outbox.length === 0
|
|
231
|
+
&& ![...store.processedEvents.values()].some((r) => r.state === 'completed'),
|
|
232
|
+
'a crash before the commit left part of the application behind — half a payment no retry can see');
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: 'crash-before-commit-applies-nothing',
|
|
237
|
+
fixtures: [CYCLE_JAN, CYCLE_JAN_REDELIVERY],
|
|
238
|
+
states: 'after a mid-transaction crash, the retry applies everything exactly once',
|
|
239
|
+
// `claim-expiry` is here because the retry enters through the expired-claim door.
|
|
240
|
+
breaks: ['atomic-application', 'claim-expiry', 'grant-on-renewal'],
|
|
241
|
+
async run({ store, handler, assert }) {
|
|
242
|
+
const dead = await handler.deliver(fixture(CYCLE_JAN), { crashBeforeCommit: true });
|
|
243
|
+
assert.unmutated.equal(dead.status, 0, 'the crash simulation must not answer');
|
|
244
|
+
store.advanceClock(CLAIM_TTL_MS + 1);
|
|
245
|
+
const retry = await handler.deliver(fixture(CYCLE_JAN_REDELIVERY));
|
|
246
|
+
assert.deepEqual(retry.body, { received: true },
|
|
247
|
+
'the retry after the crash was not let in');
|
|
248
|
+
assert.equal(store.grants.length, 1, 'the retry did not apply the payment exactly once');
|
|
249
|
+
assert.equal(store.notifications.length, 1, 'the renewal notice did not go out exactly once');
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: 'committed-retry-sends-once',
|
|
254
|
+
fixtures: [CYCLE_JAN],
|
|
255
|
+
states: 'after the commit, a redelivered outbox row sends nothing twice — the consumer holds its own key',
|
|
256
|
+
breaks: ['outbox-consumer-key', 'grant-on-renewal'],
|
|
257
|
+
async run({ store, handler, assert }) {
|
|
258
|
+
await handler.deliver(fixture(CYCLE_JAN));
|
|
259
|
+
assert.equal(store.notifications.length, 1, 'the first drain did not send');
|
|
260
|
+
// The queue redelivers: every sent row comes back pending. At-least-once is
|
|
261
|
+
// the outbox's contract, so this is the normal case, not the weird one.
|
|
262
|
+
for (const row of store.outbox) row.state = 'pending';
|
|
263
|
+
handler.drainOutbox();
|
|
264
|
+
assert.equal(store.notifications.length, 1, '"your renewal" was sent twice');
|
|
265
|
+
assert.equal(store.conversions.length, 1,
|
|
266
|
+
'the conversion fired twice — the revenue is counted twice');
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: 'concurrent-entry-points-grant-once',
|
|
271
|
+
fixtures: [CYCLE_JAN],
|
|
272
|
+
states: 'a webhook and the reconciler racing for one period leave exactly one keyed ledger row — the claim is an INSERT, and a read is a round trip',
|
|
273
|
+
breaks: ['grant-key-atomic'],
|
|
274
|
+
async run({ store, handler, assert }) {
|
|
275
|
+
const event = fixture(CYCLE_JAN);
|
|
276
|
+
const subId = subscriptionIdOf(event.data.object);
|
|
277
|
+
await Promise.all([
|
|
278
|
+
handler.deliver(event),
|
|
279
|
+
handler.reconcile(subId, periodOfCycle(event), { userId: 'usr_PLACEHOLDER_alice' }),
|
|
280
|
+
]);
|
|
281
|
+
// Judged on the LEDGER, deliberately: the keyed row is the atomic claim's own
|
|
282
|
+
// axis. Grant counts belong to the marker invariants; measuring them here would
|
|
283
|
+
// blur the two rules this pack keeps isolated.
|
|
284
|
+
assert.equal(store.grantLedger.length, 1,
|
|
285
|
+
`the race left ${store.grantLedger.length} keyed ledger rows for one period`);
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
id: 'serialization-conflict-does-not-lose-the-renewal',
|
|
290
|
+
fixtures: [CYCLE_JAN],
|
|
291
|
+
states: 'a conflicted transaction retries inside the same claim and the renewal lands — a conflict delays a grant, never loses it',
|
|
292
|
+
breaks: ['tx-retry-bounded', 'grant-on-renewal'],
|
|
293
|
+
async run({ store, handler, assert }) {
|
|
294
|
+
store.failNextTransactions = 1;
|
|
295
|
+
const r = await handler.deliver(fixture(CYCLE_JAN));
|
|
296
|
+
assert.unmutated.equal(r.status, 200);
|
|
297
|
+
assert.equal(store.grants.length, 1,
|
|
298
|
+
'the conflicted transaction was dropped and the renewal never landed');
|
|
299
|
+
assert.equal(store.txAttempts, 2, 'the retry did not actually run a second attempt');
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: 'exhausted-retries-answer-5xx-and-the-redelivery-lands',
|
|
304
|
+
fixtures: [CYCLE_JAN, CYCLE_JAN_REDELIVERY],
|
|
305
|
+
states: 'past the bound the route answers 5xx with the claim released, and the redelivery grants exactly once',
|
|
306
|
+
// atomic-application is here because without the rollback, the conflicted
|
|
307
|
+
// attempts leave their halves behind and every count below drifts.
|
|
308
|
+
breaks: ['atomic-application', 'tx-retry-bounded', 'grant-on-renewal'],
|
|
309
|
+
async run({ store, handler, assert }) {
|
|
310
|
+
store.failNextTransactions = TX_RETRIES + 1;
|
|
311
|
+
const first = await handler.deliver(fixture(CYCLE_JAN));
|
|
312
|
+
assert.equal(first.status, 500,
|
|
313
|
+
`exhausted retries answered ${first.status} — a swallowed conflict lies "done"`);
|
|
314
|
+
assert.equal(store.grants.length, 0,
|
|
315
|
+
'grants landed from attempts that were supposed to roll back');
|
|
316
|
+
const retry = await handler.deliver(fixture(CYCLE_JAN_REDELIVERY));
|
|
317
|
+
assert.equal(retry.status, 200, 'the redelivery was not let back in');
|
|
318
|
+
assert.equal(store.grants.length, 1, 'the redelivery did not land the renewal exactly once');
|
|
153
319
|
},
|
|
154
320
|
},
|
|
155
321
|
{
|