@ssheleg/sheleg-dev 0.4.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 (32) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/LICENSE +21 -0
  3. package/README.md +132 -0
  4. package/SECURITY.md +63 -0
  5. package/bin/sheleg-dev.js +99 -0
  6. package/package.json +45 -0
  7. package/plugins/sheleg-dev/.claude-plugin/plugin.json +28 -0
  8. package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +442 -0
  9. package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +257 -0
  10. package/plugins/sheleg-dev/skills/ad-tracking/references/event-tracking.md +201 -0
  11. package/plugins/sheleg-dev/skills/ad-tracking/references/gtag-api.md +389 -0
  12. package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +103 -0
  13. package/plugins/sheleg-dev/skills/ad-tracking/references/performance-security.md +288 -0
  14. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +384 -0
  15. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +1357 -0
  16. package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +147 -0
  17. package/plugins/sheleg-dev/skills/frontend-performance/references/accessibility-perf.md +172 -0
  18. package/plugins/sheleg-dev/skills/frontend-performance/references/css-performance.md +180 -0
  19. package/plugins/sheleg-dev/skills/frontend-performance/references/nextjs.md +271 -0
  20. package/plugins/sheleg-dev/skills/google-auth/SKILL.md +275 -0
  21. package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +464 -0
  22. package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +615 -0
  23. package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +593 -0
  24. package/plugins/sheleg-dev/skills/google-auth/references/workload-identity.md +376 -0
  25. package/plugins/sheleg-dev/skills/google-signin/SKILL.md +121 -0
  26. package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +421 -0
  27. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +458 -0
  28. package/plugins/sheleg-dev/skills/stripe-billing/references/price-integrity.md +201 -0
  29. package/plugins/sheleg-dev/skills/stripe-billing/references/stripe-agent-toolchain.md +173 -0
  30. package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +294 -0
  31. package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +214 -0
  32. package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +211 -0
@@ -0,0 +1,421 @@
1
+ # Google Sign-In (GIS) — Complete Guide: How It Works, How to Set It Up
2
+
3
+ > Self-contained, shareable guide. Explains the Google Identity Services (GIS)
4
+ > ID-token flow end-to-end, how to configure it from scratch in any web app,
5
+ > and the full security checklist. The reference implementation cited
6
+ > throughout is Prowl (`web/auth.py`, `web/server.py`, `web/static/js/app.js`)
7
+ > — but every section works standalone for any stack.
8
+
9
+ ## Contents
10
+
11
+ - [1. The two Google auth flows — and which one this is](#1-the-two-google-auth-flows--and-which-one-this-is)
12
+ - [2. How the GIS flow works — step by step](#2-how-the-gis-flow-works--step-by-step)
13
+ - [3. Setup from scratch (~10 minutes)](#3-setup-from-scratch-10-minutes)
14
+ - [4. Security checklist (each item maps to a real attack)](#4-security-checklist-each-item-maps-to-a-real-attack)
15
+ - [5. Troubleshooting](#5-troubleshooting)
16
+ - [6. Reference implementation map (Prowl)](#6-reference-implementation-map-prowl)
17
+ - [7. Minimal end-to-end skeleton (copy-paste starting point, FastAPI)](#7-minimal-end-to-end-skeleton-copy-paste-starting-point-fastapi)
18
+
19
+
20
+ ---
21
+
22
+ ## 1. The two Google auth flows — and which one this is
23
+
24
+ Google offers two distinct browser auth mechanisms. Do not mix them up:
25
+
26
+ | | **GIS ID-token flow** (this guide) | OAuth 2.0 authorization-code flow |
27
+ |---|---|---|
28
+ | Purpose | *Authentication* — "who is this user?" | *Authorization* — "let me call Google APIs on the user's behalf" |
29
+ | What you get | One **ID token** (a JWT signed by Google) | `code` → exchanged server-side for `access_token` + `refresh_token` |
30
+ | Redirect URI needed | **No** (popup/One Tap posts the token to your JS callback) | Yes |
31
+ | Client secret needed | **No** (verification uses Google's public keys) | Yes |
32
+ | Google APIs callable after | None | Yes (Gmail, Drive, Calendar… per granted scopes) |
33
+ | Library | `https://accounts.google.com/gsi/client` | `google-auth-oauthlib` / OAuth endpoints |
34
+
35
+ If you only need "Sign in with Google" → GIS ID-token flow. Simpler, no
36
+ secrets, no redirect URIs, no token storage for Google APIs.
37
+
38
+ ## 2. How the GIS flow works — step by step
39
+
40
+ ```
41
+ Browser Google Your backend
42
+ ─────── ────── ────────────
43
+ 1. load gsi/client script
44
+ 2. google.accounts.id.initialize({client_id, callback, nonce})
45
+ 3. renderButton(#container)
46
+ 4. user clicks button ──────► account-chooser popup
47
+ (user picks account,
48
+ consents on first use)
49
+ 5. callback receives ◄────── ID token (JWT, RS256-signed
50
+ {credential: "<jwt>"} by Google's private key)
51
+ 6. POST /api/auth/google {credential, nonce} ─────────────► 7. verify ID token:
52
+ - signature vs Google JWKS
53
+ (https://www.googleapis.com/oauth2/v3/certs)
54
+ - aud == YOUR client_id
55
+ - iss == accounts.google.com
56
+ - exp not passed
57
+ - email_verified == true
58
+ - nonce matches
59
+ 8. find-or-create user,
60
+ link accounts
61
+ 9. issue YOUR OWN session
62
+ (app JWT / cookie)
63
+ 10. store session, user is logged in ◄─────────────────────
64
+ ```
65
+
66
+ Key mental model: **the Google ID token is proof of identity, not a session.**
67
+ You verify it once, then issue your *own* session token. Google's token is
68
+ never stored and never used again.
69
+
70
+ ### 2.1 What's inside the ID token
71
+
72
+ A standard JWT with three base64url parts (`header.payload.signature`).
73
+ Payload claims you care about:
74
+
75
+ ```json
76
+ {
77
+ "iss": "https://accounts.google.com",
78
+ "aud": "1234567890-abc.apps.googleusercontent.com", // YOUR client ID
79
+ "sub": "10769150350006150715113082367", // stable Google user ID
80
+ "email": "user@gmail.com",
81
+ "email_verified": true,
82
+ "name": "Ada Lovelace",
83
+ "picture": "https://lh3.googleusercontent.com/…",
84
+ "nonce": "d9b2d63d-…", // echoed from initialize() — replay defense
85
+ "iat": 1719410000,
86
+ "exp": 1719413600 // ~1 hour lifetime
87
+ }
88
+ ```
89
+
90
+ - **`sub` is the permanent user key.** Emails can change; `sub` never does.
91
+ Store `sub` as `google_id` and match returning users by it, not by email.
92
+ - **`aud` must equal your client ID** — otherwise a token minted for another
93
+ site would log users into yours.
94
+ - The signature is RS256 over Google's rotating public keys (JWKS). Client
95
+ libraries fetch and cache those keys for you.
96
+
97
+ ### 2.2 Frontend (reference: `web/static/js/app.js:16-110`, `web/static/index.html:246-296`)
98
+
99
+ ```html
100
+ <!-- index.html -->
101
+ <meta name="google-client-id" content="__GOOGLE_CLIENT_ID__"> <!-- injected server-side from env -->
102
+ <script src="https://accounts.google.com/gsi/client" async defer></script>
103
+ <div id="google-signin-btn"></div>
104
+ ```
105
+
106
+ ```js
107
+ // app.js (essentials)
108
+ var _googleNonce = '';
109
+
110
+ function _renderGoogleButton() {
111
+ var clientId = _getGoogleClientId(); // read from the <meta> tag
112
+ if (!clientId) return; // Google auth not configured → hide, degrade honestly
113
+ if (typeof google === 'undefined' || !google.accounts) {
114
+ // gsi/client loads async — retry up to 20 × 150 ms instead of dropping the button
115
+ if (_googleRenderRetries++ < 20) setTimeout(_renderGoogleButton, 150);
116
+ return;
117
+ }
118
+ _googleNonce = crypto.randomUUID(); // fresh nonce per render — replay defense
119
+
120
+ google.accounts.id.initialize({
121
+ client_id: clientId,
122
+ callback: handleGoogleCredential,
123
+ auto_select: false,
124
+ cancel_on_tap_outside: true,
125
+ nonce: _googleNonce,
126
+ });
127
+ google.accounts.id.renderButton(
128
+ document.getElementById('google-signin-btn'),
129
+ { theme: 'outline', size: 'large', type: 'standard', text: 'continue_with', width: 308 },
130
+ );
131
+ }
132
+
133
+ async function handleGoogleCredential(response) {
134
+ // response.credential = the Google-signed ID token (JWT)
135
+ var resp = await fetch('/api/auth/google', {
136
+ method: 'POST',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify({ credential: response.credential, nonce: _googleNonce }),
139
+ });
140
+ var data = await resp.json();
141
+ if (!resp.ok) { showError(data.detail); return; }
142
+ // backend has set an HttpOnly session cookie; store display fields only
143
+ AppState.saveCredentials(data.access_token, data.email, data.user_id, /* … */);
144
+ }
145
+ ```
146
+
147
+ On logout, also call `google.accounts.id.disableAutoSelect()` so One Tap
148
+ doesn't silently re-login the user.
149
+
150
+ **Why serve the client ID via a server-injected `<meta>` tag:** one source of
151
+ truth (the `GOOGLE_CLIENT_ID` env var), no hardcoded IDs in JS bundles, and
152
+ environments (dev/staging/prod) can use different OAuth clients without a
153
+ rebuild. The client ID is public — not a secret — but drift between
154
+ environments is a classic outage.
155
+
156
+ ### 2.3 Backend verification (reference: `web/auth.py:230-255`)
157
+
158
+ Never decode the JWT yourself and trust the payload. Use Google's library —
159
+ it fetches/caches JWKS, checks signature, `aud`, `iss`, `exp`:
160
+
161
+ ```python
162
+ # pip install google-auth
163
+ from google.oauth2 import id_token
164
+ from google.auth.transport import requests as google_requests
165
+
166
+ _transport = google_requests.Request() # reuse one instance (connection pooling)
167
+
168
+ def verify_google_token(credential: str) -> dict:
169
+ if not GOOGLE_CLIENT_ID:
170
+ raise ValueError("Google Sign-In is not configured (GOOGLE_CLIENT_ID missing)")
171
+ try:
172
+ payload = id_token.verify_oauth2_token(credential, _transport, GOOGLE_CLIENT_ID)
173
+ except Exception as exc:
174
+ raise ValueError(f"Invalid Google token: {exc}") from exc
175
+ if not payload.get("email_verified"):
176
+ raise ValueError("Google account email is not verified")
177
+ return payload
178
+ ```
179
+
180
+ Node equivalent: `google-auth-library` → `client.verifyIdToken({ idToken, audience: CLIENT_ID })`.
181
+
182
+ ### 2.4 Account linking — the three-way branch (reference: `web/auth.py:358-435`)
183
+
184
+ Every Google login resolves to exactly one of three cases:
185
+
186
+ 1. **Returning Google user** — a record with this `google_id` (`sub`) exists
187
+ → 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.
196
+
197
+ Then issue your own session. Prowl issues an HS256 app JWT (72 h, `ver` claim
198
+ for global revocation) delivered as an **HttpOnly, Secure, SameSite=Strict
199
+ cookie** — out of reach of JS/XSS, never sent cross-site.
200
+
201
+ ## 3. Setup from scratch (~10 minutes)
202
+
203
+ ### 3.1 Google Cloud Console
204
+
205
+ 1. <https://console.cloud.google.com/> → create/pick a project.
206
+ 2. **APIs & Services → OAuth consent screen**: user type **External**, fill
207
+ app name / support email / developer email. Scopes: none needed beyond the
208
+ defaults (GIS only uses `openid email profile`). Publish the app (while
209
+ "Testing", only allowlisted test users can sign in).
210
+ 3. **APIs & Services → Credentials → Create credentials → OAuth client ID**:
211
+ - Application type: **Web application**.
212
+ - **Authorized JavaScript origins**: every origin the button renders on —
213
+ e.g. `https://yourapp.com`, `https://www.yourapp.com`, and for dev
214
+ `http://localhost:8000` (origin = scheme+host+port, **no path, no
215
+ trailing slash**).
216
+ - **Authorized redirect URIs: leave empty** — the GIS popup flow doesn't
217
+ redirect.
218
+ 4. Copy the **Client ID** (`…apps.googleusercontent.com`). There is a client
219
+ secret on that page — the GIS ID-token flow **never uses it**; don't ship it.
220
+
221
+ ### 3.2 App configuration
222
+
223
+ ```bash
224
+ # .env
225
+ GOOGLE_CLIENT_ID=1234567890-abc123.apps.googleusercontent.com
226
+ ```
227
+
228
+ - Inject it into the page server-side (Prowl: `server.py` replaces
229
+ `__GOOGLE_CLIENT_ID__` in the served HTML with the env value).
230
+ - If the env var is empty → the button silently doesn't render and the
231
+ backend returns "not configured". Honest degradation, no crash.
232
+
233
+ ### 3.3 Content-Security-Policy
234
+
235
+ If you set a CSP, GIS needs (reference: `web/server.py:625-642`):
236
+
237
+ ```
238
+ script-src … https://accounts.google.com;
239
+ connect-src … https://accounts.google.com;
240
+ frame-src … https://accounts.google.com;
241
+ ```
242
+
243
+ ### 3.4 Dependencies
244
+
245
+ ```bash
246
+ pip install google-auth # Python
247
+ npm i google-auth-library # Node
248
+ ```
249
+
250
+ ## 4. Security checklist (each item maps to a real attack)
251
+
252
+ ### 4.1 Token verification — non-negotiable
253
+
254
+ - ☑ Verify **signature** against Google JWKS (the library does it). Never
255
+ `jwt.decode(..., verify=False)`.
256
+ - ☑ Verify **`aud` == your client ID** — blocks tokens minted for other apps.
257
+ - ☑ Verify **`exp`** (library) — blocks stale tokens.
258
+ - ☑ Require **`email_verified == true`** — Google accounts can exist with
259
+ unverified emails (e.g. Workspace-imported); skipping this lets an attacker
260
+ claim someone else's address.
261
+
262
+ ### 4.2 Nonce — replay defense
263
+
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.
269
+
270
+ ### 4.3 Account pre-hijacking guard (P3-01 in Prowl)
271
+
272
+ Attack: attacker registers `victim@gmail.com` with a password *before* the
273
+ victim ever visits. Later the victim clicks "Sign in with Google". Naïve
274
+ auto-linking attaches victim's Google to the **attacker's** record — attacker
275
+ keeps password access to the merged account (its data and wallet).
276
+
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.
281
+
282
+ ### 4.4 Login-CSRF (P3-03 in Prowl)
283
+
284
+ Attack: attacker's page force-POSTs the **attacker's own** Google credential
285
+ to your `/api/auth/google`; the victim's browser gets logged into the
286
+ attacker's account (victim then unknowingly feeds data/payments into it).
287
+
288
+ Defense (`web/server.py:1657-1686`), covering both possible delivery flows:
289
+
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.
296
+
297
+ ### 4.5 Session hygiene
298
+
299
+ - ☑ Session cookie: **HttpOnly + Secure + SameSite=Strict** (XSS-exfil and
300
+ CSRF resistant). `web/server.py:1604-1620`.
301
+ - ☑ Your app JWT carries a **version claim** (`ver`) checked against the
302
+ user's stored `auth_version` → bumping it revokes all outstanding sessions
303
+ ("log out everywhere").
304
+ - ☑ Rate-limit `/api/auth/*` (unauthenticated, internet-reachable).
305
+ - ☑ Log auth events (success/failure, user id prefix) — never log the
306
+ credential/JWT itself.
307
+
308
+ ### 4.6 What is public vs secret
309
+
310
+ | Value | Status |
311
+ |---|---|
312
+ | `GOOGLE_CLIENT_ID` | Public (visible in page source — fine) |
313
+ | Google client secret | **Unused** in GIS flow — don't put it in the app at all |
314
+ | Your app JWT secret (`WEB_JWT_SECRET`) | Secret — rotate via runbook; refuse to boot in prod with a default |
315
+ | Google ID token | Sensitive in transit; verify once, never store, never log |
316
+
317
+ ## 5. Troubleshooting
318
+
319
+ | Symptom | Cause → fix |
320
+ |---|---|
321
+ | Button never renders | `gsi/client` not loaded yet (retry loop handles it); or empty client ID meta; or CSP blocks `accounts.google.com` |
322
+ | `[GSI_LOGGER] The given origin is not allowed for the given client ID` | Current origin missing from **Authorized JavaScript origins** (check port and `www.` exactly). Changes take minutes to propagate |
323
+ | `Invalid Google token: Token used too early/expired` | Server clock skew → sync NTP |
324
+ | `Wrong recipient / audience mismatch` | Frontend and backend use different client IDs — single env source of truth |
325
+ | 403 `access_denied` at consent | OAuth consent screen still in **Testing** and the user isn't a test user → publish the app |
326
+ | Works locally, fails in prod | Prod origin not in Authorized origins; or cookie has `Secure` on plain HTTP (Prowl dev: `PROWL_INSECURE_COOKIES=1`) |
327
+ | One Tap doesn't show / FedCM warnings | Third-party-cookie phase-out: GIS auto-migrates to FedCM; keep `gsi/client` unmodified and current — the rendered button flow is unaffected |
328
+ | User "lost password" but signed up via Google | Expected: Google-only accounts have empty password hash. Recovery = sign in with Google using the same email |
329
+
330
+ ## 6. Reference implementation map (Prowl)
331
+
332
+ | Concern | Where |
333
+ |---|---|
334
+ | Client ID config | `web/auth.py:72` (`GOOGLE_CLIENT_ID` env) |
335
+ | Token verification | `web/auth.py:230-255` (`verify_google_token`) |
336
+ | Three-way linking + pre-hijack guard | `web/auth.py:358-435` (`google_auth_user`) |
337
+ | Endpoint + CSRF (both flows) | `web/server.py:1657-1690` (`/api/auth/google`) |
338
+ | Cookie policy | `web/server.py:1596-1620` |
339
+ | CSP allowances | `web/server.py:625-642` |
340
+ | Frontend button + nonce + callback | `web/static/js/app.js:16-110` |
341
+ | GIS script + meta + container | `web/static/index.html:246-296` |
342
+ | App JWT issue/verify/revocation | `web/auth.py:151-209` |
343
+ | Email canonicalization (anti-abuse) | `web/auth.py:116-145` |
344
+
345
+ ## 7. Minimal end-to-end skeleton (copy-paste starting point, FastAPI)
346
+
347
+ ```python
348
+ # --- backend ---
349
+ import os, hmac
350
+ from fastapi import FastAPI, HTTPException, Request, Response
351
+ from pydantic import BaseModel
352
+ from google.oauth2 import id_token
353
+ from google.auth.transport import requests as google_requests
354
+
355
+ GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
356
+ _transport = google_requests.Request()
357
+ app = FastAPI()
358
+
359
+ class GoogleAuthRequest(BaseModel):
360
+ credential: str
361
+ nonce: str | None = None
362
+ g_csrf_token: str | None = None
363
+
364
+ @app.post("/api/auth/google")
365
+ 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")
373
+
374
+ try:
375
+ payload = id_token.verify_oauth2_token(data.credential, _transport, GOOGLE_CLIENT_ID)
376
+ except Exception as exc:
377
+ raise HTTPException(401, f"Invalid Google token: {exc}")
378
+ if not payload.get("email_verified"):
379
+ 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")
382
+
383
+ user = await find_or_create_user( # your 3-way linking here (§2.4, §4.3)
384
+ google_id=payload["sub"], email=payload["email"].lower().strip(),
385
+ name=payload.get("name"), picture=payload.get("picture"),
386
+ )
387
+ session_jwt = issue_app_session(user) # your own JWT/session
388
+ response.set_cookie("auth", session_jwt, httponly=True, secure=True,
389
+ samesite="strict", max_age=72 * 3600, path="/")
390
+ return {"user_id": user.id, "email": user.email}
391
+ ```
392
+
393
+ ```html
394
+ <!-- --- frontend --- -->
395
+ <script src="https://accounts.google.com/gsi/client" async defer></script>
396
+ <div id="gbtn"></div>
397
+ <script>
398
+ const NONCE = crypto.randomUUID();
399
+ window.onload = () => {
400
+ google.accounts.id.initialize({
401
+ client_id: "YOUR_CLIENT_ID.apps.googleusercontent.com",
402
+ nonce: NONCE,
403
+ callback: async ({ credential }) => {
404
+ const r = await fetch("/api/auth/google", {
405
+ method: "POST",
406
+ headers: { "Content-Type": "application/json" },
407
+ body: JSON.stringify({ credential, nonce: NONCE }),
408
+ });
409
+ if (r.ok) location.href = "/app";
410
+ },
411
+ });
412
+ google.accounts.id.renderButton(document.getElementById("gbtn"),
413
+ { theme: "outline", size: "large", text: "continue_with" });
414
+ };
415
+ </script>
416
+ ```
417
+
418
+ ---
419
+
420
+ *Last verified against the Prowl codebase on 2026-07-08. If line numbers
421
+ drift, search for the function names — they are stable.*