@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.
Files changed (24) hide show
  1. package/CHANGELOG.md +47 -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 +35 -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 +39 -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 +62 -23
  12. package/plugins/sheleg-dev/skills/google-auth/SKILL.md +22 -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 +64 -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 +26 -9
  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
@@ -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
- 2. google.accounts.id.initialize({client_id, callback, nonce})
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, nonce} ─────────────► 7. verify ID token:
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 matches
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-…", // echoed from initialize() — replay defense
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
- _googleNonce = crypto.randomUUID(); // fresh nonce per render — replay defense
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, nonce: _googleNonce }),
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 email/password account, no `google_id`** — link Google to it
189
- (set `google_id`, mark `email_verified=True`) → issue session.
190
- ⚠️ **Pre-hijacking guard** (see §4.3): only auto-link if the existing
191
- account's email ownership was verified. Otherwise refuse with "sign in
192
- with your password".
193
- 3. **Brand-new user** — create the account with `email_verified=True` (the
194
- Google token already proved inbox ownership), empty password hash → issue
195
- session.
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
- Generate a fresh random nonce per button render, pass it to
265
- `google.accounts.id.initialize({nonce})`, send it alongside the credential,
266
- compare server-side with the token's `nonce` claim
267
- (`web/auth.py:375-376`). A stolen/logged ID token can't be replayed later
268
- because the nonce won't match the new session's nonce.
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 (`web/auth.py:392-417`): when a Google login matches an existing
278
- **password** account whose email was **never verified**, refuse to auto-link;
279
- tell the user to sign in with the password. Link only when email ownership of
280
- the existing record is proven.
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
- - **Form-POST flow** (GIS `login_uri` auto-POST): GIS double-submits
291
- `g_csrf_token` in body *and* cookie — compare both, constant-time
292
- (`hmac.compare_digest`). Reject if either is missing.
293
- - **JS-fetch flow** (this app): no `g_csrf_token`, so enforce same-origin via
294
- **Fetch-Metadata** (`Sec-Fetch-Site: same-origin`) with an Origin-allowlist
295
- fallback. These headers are browser-set and unforgeable by page script.
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
- nonce: str | None = None
362
- g_csrf_token: str | None = None
363
-
364
- @app.post("/api/auth/google")
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
- # CSRF: double-submit for form-POST flow, Fetch-Metadata for JS flow
367
- if data.g_csrf_token:
368
- cookie = req.cookies.get("g_csrf_token") or ""
369
- if not hmac.compare_digest(cookie, data.g_csrf_token):
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(data.credential, _transport, GOOGLE_CLIENT_ID)
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
- if data.nonce and payload.get("nonce") != data.nonce:
381
- raise HTTPException(401, "Nonce mismatch")
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
- const NONCE = crypto.randomUUID();
399
- window.onload = () => {
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, nonce: NONCE }),
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 across modes, claim-first webhook idempotency, what invoice
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 === "duplicate") return json({ received: true, duplicate: true });
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.** `SELECT` then `INSERT` is a race; two deliveries
219
- 40 ms apart both pass the read and both credit. Release the claim if
220
- processing throws, or the retry finds the event "processed" and the work is
221
- lost forever.
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, then side effects that must not run twice (emails, provisioning)
227
- after it commits.
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` | `if (!store.claimEvent(event.id)) return duplicate` | reads the processed table, then writes it — two deliveries 40 ms apart both pass the read |
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
- breaks: ['claim', 'grant-on-renewal'],
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
- assert.equal(bodies.filter((x) => x.duplicate === true).length, 1,
152
- 'exactly one of the two deliveries must answer duplicate');
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
  {