# F4 > F4 is the engineering record of Ratomir Jovanovic: projects, technical decisions, failures and fixes, each linked to the code that supports it. F4 is the personal engineering site of Ratomir Jovanovic. GitHub: https://github.com/ratomirjovanovic. This file holds the full text of the published content; https://www.f4.rs/llms.txt is the short index. ## Projects ### PandaPayLens An open-source, local-first toolkit that decodes payment provider errors, webhooks and event logs, rebuilds the transaction timeline and explains what looks wrong, without sending the data anywhere. URL: https://www.f4.rs/work/pandapaylens Source: https://github.com/ratomirjovanovic/PandaPayLens Live: https://ratomirjovanovic.github.io/PandaPayLens/ Package: https://www.npmjs.com/package/pandapaylens Status: active Role: Creator and maintainer. All 58 commits on main and all 39 merged pull requests up to release 0.14.0 are authored under my GitHub account. The repository was created on 2026-10-25 and shipped 14 releases in its first day. Stack: TypeScript 6.0, Next.js 16.4, React 19.3, Web Worker, esbuild 0.28, Vitest + fast-check 5.0 / 4.10, Playwright 1.64, pnpm workspaces 10.29 #### Problem When a payment integration misbehaves, the evidence is a pile of provider-specific JSON, XML and webhook deliveries. Reading it means knowing each provider's formats, error codes and delivery habits, and the data is often too sensitive to paste into an online tool. #### Facts - Provider adapters: 40 of 42 roadmap providers (https://github.com/ratomirjovanovic/PandaPayLens/blob/0055efc/packages/providers/src/index.ts) - Fully supported: Stripe and Adyen; 38 others limited, with documented gaps (https://github.com/ratomirjovanovic/PandaPayLens/blob/0055efc/README.md) - Diagnostic rules: 15 deterministic rules (https://github.com/ratomirjovanovic/PandaPayLens/blob/0055efc/packages/rules/src/index.ts) - Tests at 0.14.0: 4,900 reported passing (https://github.com/ratomirjovanovic/PandaPayLens/pull/75) - Synthetic fixtures: 769 files (https://github.com/ratomirjovanovic/PandaPayLens/tree/0055efc/fixtures) - License: Apache-2.0 (https://github.com/ratomirjovanovic/PandaPayLens/blob/0055efc/LICENSE) #### Context Payment providers each describe a payment differently. A failed charge can arrive as a JSON error from an API, an XML document from an older gateway, a form-encoded postback, a raw HTTP response with a plain-text body, or a series of webhook deliveries that may be duplicated or out of order. Debugging means translating all of that into one picture: which events belong to the same transaction, what the provider actually reported, and what is merely missing. PandaPayLens takes pasted input from 40 providers, detects the provider and format, normalizes the records, rebuilds the timeline and runs a catalog of explainable rules over it. It runs in three places from the same engine: a web analyzer, a static demo on GitHub Pages, and an offline CLI published to npm as `pandapaylens`. The design constraint that shapes most decisions is privacy. Payment payloads contain card data, names, emails and credentials, so the analysis must run where the data already is: in the browser or on the developer's machine. #### Constraints - No payment data may leave the device: Analysis runs in a Web Worker in the browser or in the local CLI. The web app sets connect-src 'self' in its Content Security Policy, so the browser itself blocks requests to other origins. - No AI or network calls in the diagnostic path: Findings come from deterministic rules with evidence, confidence and stated limitations. The engine package has no runtime dependencies. - Provider documentation is the only source of truth: Two roadmap providers stay blocked because they publish no developer documentation. Adapters are not built from guesses. - Untrusted, possibly hostile input: Pasted text can be enormous, deeply nested or crafted. Parsing must fail with typed errors rather than stack overflows, quadratic regexes or entity expansion. #### Architecture The repository is a pnpm workspace. `packages/core` holds the engine with no dependencies, `packages/adapters/*` holds one package per provider, `packages/rules` holds the diagnostic catalog, `packages/providers` is the registry, and `packages/cli` bundles everything into the npm package. Two apps reuse the same pages: `apps/web` for Vercel and `apps/pages` as a static export for GitHub Pages. The analysis is a pipeline. `parseInput` decides the format, every adapter scores how likely the record is its provider, the best candidate above a threshold of 0.5 normalizes it, and the engine sorts, correlates and evaluates rules. Diagram: From pasted text to findings Pasted text is parsed by parseInput into JSON, JSONL, form, HTTP, XML or name-value text. Each adapter scores the record in detect; the best match above 0.5 is normalized and its errors decoded. A time guard drops timestamps without a zone, sortEvents orders events, correlate groups them by shared identifiers with union-find, and 15 rules produce findings that the report masks and exports. 1. Parse: JSON, JSONL, form, HTTP, XML, name: value text (`parseInput`) 2. Detect: Every adapter scores; threshold 0.5, ambiguity margin 0.15 (`detect`) 3. Normalize: Allowlisted fields, amounts as integer minor units (`normalize`) 4. Order: Zoned timestamps only; untimed events keep their place (`sortEvents`) 5. Correlate: Union-find over explicit identifiers (`correlate`) 6. Diagnose: 15 rules with evidence, confidence and limitations (`defaultRules`) #### Implementation Each adapter implements the same interface: `detect`, `parse`, `normalize`, `decodeErrors` and `correlationKeys`, plus a capability manifest that states what is supported, limited or experimental. A manifest capability is marked supported only when it is implemented and tested. Normalization copies only allowlisted fields. Amounts are kept as integer strings in minor units, never floats, and an `UNSAFE_INTEGER` warning is raised when a value is not an exact integer. Every normalized event carries a coarse lifecycle stage, but the type documentation is explicit that this is a display aid: authorization is not settlement, and a capture is not necessarily settlement either. Rules are pure functions of the normalized context. A rule that throws becomes a `RULE_FAILED` warning instead of crashing the analysis, and finding text never quotes payment identifiers, so masking in reports stays effective. #### Technical decisions **Recognize repeated deliveries even when the provider sends no delivery id** Problem: Duplicate detection relied on each adapter's delivery key. Providers such as dLocal, Paystack and PayU India send notifications without one, so a refund notification delivered twice could make the amount rule report refunds larger than the payment. Decision: The engine adds a content fingerprint to every event: a 64-bit FNV-1a pair over the provider, format, event path and the record serialized with sorted keys. Repeats keyed by a delivery id are reported as confirmed; repeats found by identical content are reported as possible. Alternatives: Treating identical content as a confirmed duplicate was rejected: two separate events with exactly the same fields would look the same. Adapters can set contentMayRepeat where a provider documents that identical records are separate events (Paymentwall partial refunds, EBANX refund and chargeback notifications). Trade-off: Content repeats remain a possibility, not proof, and the data cannot show whether the application processed the event twice. Evidence: [Issue #49](https://github.com/ratomirjovanovic/PandaPayLens/issues/49), [Pull request #50](https://github.com/ratomirjovanovic/PandaPayLens/pull/50), [repeatKey in packages/rules/src/repeat.ts](https://github.com/ratomirjovanovic/PandaPayLens/blob/0055efc/packages/rules/src/repeat.ts) **Only trust timestamps that carry a time zone** Problem: Date.parse reads a date-time without a zone in the local zone of the machine running it, so the same input could produce a different order on different machines. Decision: Event and observed timestamps are kept only when they are ISO 8601 with Z or an offset. Others are dropped and counted in a TIMESTAMP_IGNORED warning. Adapters whose providers send zone-less times (CCBill, Trust Payments) set eventTimeZoneAssumed, shown as "(UTC assumed)". Trade-off: The pull request states that all adapters already passed this check; the guard is defense in depth for future adapters. Ordering against other providers may be off by hours when UTC is assumed. Evidence: [Pull request #61](https://github.com/ratomirjovanovic/PandaPayLens/pull/61) **Parse XML with a small reader that refuses DOCTYPE** Problem: Several gateways answer in XML. A general XML parser brings external entities and entity expansion into a tool that reads untrusted input. Decision: A dependency-free reader rejects any DOCTYPE or markup declaration, decodes only the five predefined entities and numeric references, and enforces depth 64, 50,000 elements and the text length limit in one linear pass with an explicit stack. Alternatives: No adapter claimed XML at first; such records were reported as unsupported, which the pull request describes as clearer than a parse error. Adapter support followed in later releases. Trade-off: Documents with a DOCTYPE line, such as some Worldpay WPG samples, must be pasted without it. Evidence: [Issue #52](https://github.com/ratomirjovanovic/PandaPayLens/issues/52), [Pull request #53](https://github.com/ratomirjovanovic/PandaPayLens/pull/53) **Accept common JSON mistakes, but only two of them** Problem: Developers paste JSON copied from logs and editors, often with comments or trailing commas. Decision: When strict JSON.parse fails, relaxJson removes comments outside strings and commas directly before a closing bracket, then reports a LENIENT_JSON warning naming what was removed. Single quotes, unquoted keys and missing brackets are never repaired. Trade-off: A property test checks that 300 random JSON.stringify outputs pass through unchanged. Evidence: [Pull request #16](https://github.com/ratomirjovanovic/PandaPayLens/pull/16) **Publish one npm package instead of forty** Problem: The workspace has more than 40 packages; publishing each would multiply release work and supply-chain surface. Decision: The CLI and engine ship as a single unscoped package bundled with esbuild, with no runtime dependencies. A smoke test packs the tarball, installs it offline into an empty project, runs the binary and type-checks a consumer in strict mode. Publishing uses npm trusted publishing with provenance and no stored token. Trade-off: Trusted publishing can only be configured for a package that already exists, so the first version was published by hand and the workflow now exits early when the version is already on npm. Evidence: [Pull request #73](https://github.com/ratomirjovanovic/PandaPayLens/pull/73), [Pull request #74](https://github.com/ratomirjovanovic/PandaPayLens/pull/74) #### Challenges Speed of delivery created its own failure modes, and the issue tracker records them. Two regular expressions used for redaction turned out to run in quadratic time on long text without whitespace; one was flagged by CodeQL, and the companion fix measured 18 to 26 seconds for 80,000 characters before it was rewritten (see the [problem record](https://www.f4.rs/problems/linear-time-redaction-in-payment-text)). Timeline ordering pushed events without timestamps to the end, which put a Paystack webhook before the PIN and OTP steps that caused it. A shared `pay_` id prefix made Checkout.com appear as an alternative for every Razorpay payment. The documentation link checker failed on its first scheduled run with 14 Razorpay links that load from Europe but answer 404 to GitHub-hosted runners. Each of these is documented as a problem record with the fix and its validation. #### Security - The web app sends a per-request nonce Content Security Policy with `connect-src 'self'`, `object-src 'none'`, `base-uri 'none'` and `frame-ancestors 'none'`. The static GitHub Pages build cannot send headers, so it uses a meta-tag policy and a build check fails if any page lacks it. - Redaction is defense in depth, and the README says so: normalization copies only allowlisted fields, then about 330 blocked key names, 11 text patterns and Luhn-checked card numbers are redacted from everything shown or exported. Identifiers are masked in reports by default. - Pasted signature headers are never treated as proof; the tool does not verify webhook signatures and does not confirm settlement. - Keys named `__proto__`, `constructor` and `prototype` are removed during parsing and reported. - The analyzer stores no payment data. The interface does store the theme preference in the browser. - SECURITY.md states that the project is not PCI DSS certified and has not had an independent penetration test. #### Testing Tests run against source through workspace aliases. Coverage thresholds in `vitest.config.ts` are 90% statements, 85% branches, 90% functions and 90% lines. Rule tests include scenario, property (fast-check) and lifecycle suites, and every adapter has its own test file driven by synthetic fixtures whose identifiers contain `PPLsynth`. Playwright covers the analyzer and a privacy suite marked as a release blocker: it plants marker values in the input and fails if any of them appears in a network request or if any request goes to another origin. CI runs type checks, formatting, coverage, the README provider table drift check, bundle size budgets, a CLI smoke test, the npm pack smoke test and both Playwright suites. CodeQL runs on every push and weekly. #### Results - 40 provider adapters registered, 2 more blocked for lack of public documentation (https://github.com/ratomirjovanovic/PandaPayLens/blob/0055efc/packages/providers/src/manifests.ts) - 4,900 tests passing at release 0.14.0, as reported in the release pull request (https://github.com/ratomirjovanovic/PandaPayLens/pull/75) - Documentation link check: 937 links, 0 broken on the first CI run after the regional-host fix (https://github.com/ratomirjovanovic/PandaPayLens/pull/71) - Published to npm as pandapaylens with provenance (https://www.npmjs.com/package/pandapaylens) - Static demo deployed to GitHub Pages with a post-deploy live smoke test (https://github.com/ratomirjovanovic/PandaPayLens/blob/0055efc/.github/workflows/deploy-pages.yml) #### Limitations - Only Stripe and Adyen are fully supported. The other 38 providers are limited, each with documented gaps. - Redaction is best effort and does not guarantee anonymization. - The tool does not verify webhook signatures and cannot confirm settlement or fund movement. - A duplicate found by identical content is reported as possible, not confirmed. - Stripe timestamps have one-second resolution, so events created in the same second cannot be ordered. - No star count, download figures or user feedback exist yet to measure adoption; the project is one day old at the time of writing. #### Future work - Keyboard navigation in the timeline (https://github.com/ratomirjovanovic/PandaPayLens/issues/3) - CLI --output option to write reports to a file (https://github.com/ratomirjovanovic/PandaPayLens/issues/4) - More worked examples in the documentation and wiki (https://github.com/ratomirjovanovic/PandaPayLens/blob/0055efc/ROADMAP.md) #### References - [Repository](https://github.com/ratomirjovanovic/PandaPayLens) - [README](https://github.com/ratomirjovanovic/PandaPayLens/blob/0055efc/README.md) - [Roadmap](https://github.com/ratomirjovanovic/PandaPayLens/blob/0055efc/ROADMAP.md) - [Changelog](https://github.com/ratomirjovanovic/PandaPayLens/blob/0055efc/CHANGELOG.md) - [Security policy](https://github.com/ratomirjovanovic/PandaPayLens/blob/0055efc/SECURITY.md) - [Release 0.14.0](https://github.com/ratomirjovanovic/PandaPayLens/releases/tag/v0.14.0) - [Live demo](https://ratomirjovanovic.github.io/PandaPayLens/) ### PenSec An open-source, local application security assessment engine and Claude Code plugin. It reports what was checked, how, with which evidence, and what was not checked. URL: https://www.f4.rs/work/pensec Source: https://github.com/ratomirjovanovic/pensec Status: active Role: Creator and maintainer. The repository was created on 2026-10-27 and its first commit is authored under my GitHub account. Stack: Go 1.23, SQLite, YAML, Claude Code plugin, SARIF 2.1.0 #### Problem Security scanners tend to report a clean result when a check did not run, and AI review tools can present a guess as a confirmed vulnerability. A self-assessment of your own project needs to separate what was proven from what was merely not found, and must not send traffic anywhere a person has not authorized. #### Facts - Catalog: 56 controls and 19 cross-domain edges (https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/internal/catalog/catalog.go) - Built-in adapters: 7, all marked implemented but unvalidated (https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/README.md) - Control statuses: 9, of which only PASS and FAIL are decisive (https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/internal/model/model.go) - CI: gofmt, go vet, go test and build on Linux, macOS and Windows (https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/.github/workflows/ci.yml) - License: AGPL-3.0-only (https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/LICENSE) #### Context PenSec assesses the security of a repository you own. It runs as a standalone Go CLI and as a Claude Code plugin, with slash commands such as `/pensec:scan` and `/pensec:review`, read-only review agents, hooks and a local MCP server. The default profile, `local-passive`, only reads the repository. It sends no traffic to any target and executes nothing from the repository: no install scripts, Makefiles or build steps. Findings, evidence and human decisions live in a local SQLite ledger that survives context compaction and session restarts. The README states the limit plainly: PenSec is not a certified penetration test, attestation or guarantee that no vulnerabilities exist. It reports what was checked, how, with which evidence, and what was not checked. #### Constraints - Passive unless a person approves a scope: Anything that sends traffic needs a scope manifest filled in by a person and approved from an interactive terminal. The approval is bound to the manifest hash and expires. - A check that did not run is not a pass: BLOCKED, UNSUPPORTED, SUSPECTED, INCONCLUSIVE, ERROR and STALE never count as secure. A crashed or missing scanner is reported as ERROR or BLOCKED. - A model cannot confirm a finding: Model-proposed issues are stored as HYPOTHESIS, and the model cannot supply the evidence text. - Secrets are never stored in clear: Snippets are redacted before they are stored, printed, reported or returned over MCP. .env and key files are recorded by name only. - No telemetry, no account, no cloud backend #### Architecture `cmd/pensec` is the CLI. `internal/` holds the engine, the adapters, the SQLite store, scope handling, redaction, evidence, reports, the MCP server and the hook handlers. The Claude Code side is plain files: `skills/` for the `/pensec:*` commands, `agents/` for seven read-only review agents, `hooks/hooks.json` and `.mcp.json`, all of which call the same binary. An assessment starts with an inventory of files and routes, plans the applicable controls from the catalog, runs the adapters that cover them and records each run, finding and evidence file in the ledger. Cross-domain edges, such as identity to authorization, are created only when both sides share concrete assets, and their status comes from the linked controls. Diagram: From repository to report pensec init builds an inventory of files and routes. pensec plan selects applicable controls from the catalog. pensec scan runs the built-in and optional external adapters and records runs, findings and hashed evidence in the SQLite ledger. Controls get one of nine statuses, of which only PASS and FAIL are decisive. Cross-domain edges link controls that share assets. pensec report writes HTML, Markdown, JSON, SARIF and CSV. 1. Inventory: File walk, route extraction, OpenAPI drift (`pensec init`) 2. Plan: Applicable controls, gates and candidate edges (`pensec plan`) 3. Scan: Built-in adapters, optional scanners, nothing executed from the repository (`pensec scan`) 4. Record: Findings and hashed evidence in the local ledger (`.pensec/private/state.db`) 5. Decide: Only PASS and FAIL are decisive (`Status.Decisive`) 6. Report: HTML, Markdown, JSON, SARIF 2.1.0, CSV (`pensec report`) #### Implementation Every adapter declares the controls it covers, its maturity and whether it needs the network. The engine runs each one through a wrapper, so an adapter that panics produces an ERROR run instead of stopping the scan or leaving its controls looking clean, and a missing optional tool is recorded as BLOCKED. Both cases have tests. Optional scanners (gitleaks, semgrep, osv-scanner, trivy) are used only when they are already installed. They run with an argument array, a minimal environment without cloud or VCS tokens, a disposable home directory and a timeout. Semgrep's `--config auto` is refused because it sends project metadata to the registry; an explicit rule pack is required. Evidence files are written with their SHA-256 in the ledger and verified on every scan and by `pensec evidence verify`, so a modified evidence file is reported. Human decisions, such as marking a false positive or accepting a risk with an expiry date, are recorded with a name and a note. #### Technical decisions **Store AI-proposed issues as hypotheses with engine-read evidence** Problem: The review agents in the plugin can propose findings. If the model could also supply the quoted code, a mistaken or manipulated proposal could carry evidence that does not exist in the repository. Decision: The MCP tool accepts a control id, a file path and a line range, not evidence text. The engine checks that the file is a regular file inside the project (no symlinks, no .git or .pensec paths), reads at most 40 lines itself, redacts them, records the file hash and stores the finding as HYPOTHESIS with a note that it needs human verification. Trade-off: Hypotheses only make a control SUSPECTED, never FAIL, so a real issue found by review stays undecided until a person or a deterministic test confirms it. Evidence: [RecordHypothesis in internal/engine/ops.go](https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/internal/engine/ops.go), [pensec_record_hypothesis in internal/mcp/server.go](https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/internal/mcp/server.go) **Bind network approval to the manifest hash and a human terminal** Problem: A coding agent running PenSec could, in principle, write a scope manifest and approve it, or widen the scope after approval. Decision: `pensec scope approve` refuses to run when stdin is not a terminal, requires a name, shows every target and asks the person to type APPROVE followed by the first eight characters of the manifest SHA-256. The approval stores that hash and expires after 1 to 72 hours; any edit to scope.yaml invalidates it. In Claude Code a PreToolUse hook also asks for confirmation before these commands and before edits to the manifest. Trade-off: Network tests cannot be fully automated in CI; that is intended. Evidence: [scope approve in cmd/pensec/main.go](https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/cmd/pensec/main.go), [Hook configuration](https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/hooks/hooks.json) **Check the address at connection time, not only the URL** Problem: A target that is in scope by name can resolve to a private, loopback or cloud metadata address, or redirect there. Decision: The HTTP client resolves the host inside its dialer and rejects loopback, private, link-local, carrier-grade NAT and multicast addresses, and always rejects cloud metadata addresses. Redirects are never followed, and the client ignores any inherited proxy. Alternatives: Private addresses can be allowed only by an explicit manifest setting meant for a controlled local fixture. Trade-off: Only the production-observe profile exists so far; other runtime profiles are reported as UNSUPPORTED. Evidence: [AddrAllowed and DialContext in internal/scope/scope.go](https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/internal/scope/scope.go), [Redirect and private address tests](https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/internal/engine/observe_test.go) #### Challenges The project is one day old at the time of writing, and the open issues show what is missing for real use. The plugin expects a binary in `bin/`, so installing it from GitHub does not work until prebuilt binaries are published ([#4](https://github.com/ratomirjovanovic/pensec/issues/4)). The README is being rewritten for users and contributors ([#3](https://github.com/ratomirjovanovic/pensec/issues/3)), and a project website with a demo is planned ([#5](https://github.com/ratomirjovanovic/pensec/issues/5)). #### Security - The SECURITY.md scope lists the failures that matter most for this tool: repository content making PenSec run commands or confirm a finding, traffic outside the approved scope, unredacted secrets, a crashed adapter producing PASS, and evidence that can be modified without failing verification. - Reports go through redaction, and a test checks that generated reports are redacted and valid. - In Claude Code, a PreToolUse hook blocks edits to `.pensec/private/`. - PenSec has not had an external security review. SECURITY.md says so. #### Testing Go tests cover the engine against a deliberately vulnerable fixture repository, remediation and reopening of findings, staleness after an edit, a panicking adapter, a missing tool, evidence tampering and hypothesis validation. Separate tests cover the scope guard, the observe client's redirect and private address handling, redaction, report output, the hook guard decision and the MCP protocol. CI runs gofmt, go vet, go test and a build on Linux, macOS and Windows for every push to main and every pull request, with actions pinned to commit hashes and read-only token permissions. #### Results - CI passed on Linux, macOS and Windows for the first commit (https://github.com/ratomirjovanovic/pensec/actions/runs/40498959346) - 56 controls and 19 cross-domain edges in the catalog (https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/internal/catalog/catalog.go) #### Limitations - All adapters are implemented but unvalidated: they pass the fixture suite in the repository, with no published benchmark. - Runtime profiles other than production-observe are not implemented and are reported as UNSUPPORTED. - No prebuilt binaries yet; the CLI must be built with Go 1.23 or later. - Not independently audited. - No users, stars or feedback exist yet to measure adoption. #### Future work - Prebuilt binaries so the plugin works when installed from GitHub (https://github.com/ratomirjovanovic/pensec/issues/4) - A README for users and contributors (https://github.com/ratomirjovanovic/pensec/issues/3) - Project website with a live demo (https://github.com/ratomirjovanovic/pensec/issues/5) - A plugin icon (https://github.com/ratomirjovanovic/pensec/issues/1) #### References - [Repository](https://github.com/ratomirjovanovic/pensec) - [README](https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/README.md) - [Security policy](https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/SECURITY.md) - [Contributing](https://github.com/ratomirjovanovic/pensec/blob/4a14f6e/CONTRIBUTING.md) - [First commit](https://github.com/ratomirjovanovic/pensec/commit/4a14f6e) ## Problem records ### The first npm release and trusted publishing npm trusted publishing can only be configured for a package that exists, so the first version had to be published by hand, which would then make the release workflow fail. URL: https://www.f4.rs/problems/first-npm-release-with-trusted-publishing Status: resolved Project: PandaPayLens (https://www.f4.rs/work/pandapaylens) Occurred: 2026-10-26 #### Environment - Project: PandaPayLens - Runtime: TypeScript on Node.js 24 and in the browser - Component: .github/workflows/publish-npm.yml #### Symptoms The GitHub release for the hand-published version would trigger the publish workflow, which would fail on an existing version. #### Expected Publishing a release is safe to re-run and does not fail for a version that is already on npm. #### Observed npm rejects publishing a version that already exists. #### Root cause Trusted publishing is configured per package on npm, so the package had to exist before the workflow could publish to it. #### Resolution The workflow checks npm first and exits successfully when the version is already published (pull request #74): ```bash version=$(node -p "require('./package.json').version") if npm view "pandapaylens@$version" version >/dev/null 2>&1; then echo "pandapaylens@$version is already on npm." exit 0 fi pnpm pack npm publish ./pandapaylens-*.tgz --provenance --access public ``` #### Validation The pull request records that npm view was checked for both an existing and a missing version. Version 0.14.0 is on npm with provenance. #### References - [Issue #72](https://github.com/ratomirjovanovic/PandaPayLens/issues/72) - [Pull request #73](https://github.com/ratomirjovanovic/PandaPayLens/pull/73) - [Pull request #74](https://github.com/ratomirjovanovic/PandaPayLens/pull/74) ### Documentation links that were only broken from CI The first scheduled link check reported 14 broken Razorpay links. They load from Europe but answer 404 to GitHub-hosted runners. URL: https://www.f4.rs/problems/geo-dependent-404-in-link-checker Status: mitigated Project: PandaPayLens (https://www.f4.rs/work/pandapaylens) Occurred: 2026-10-26 #### Environment - Project: PandaPayLens - Runtime: TypeScript on Node.js 24 and in the browser - Component: scripts/check-doc-links.mjs - Runner: GitHub-hosted Actions runner #### Symptoms 14 Razorpay documentation links were reported broken on the first scheduled run of the monthly link check. #### Expected A link check fails only for pages that are actually gone. #### Observed The same 14 pages returned 200 from a client in Europe. They are India-only pages; Razorpay answers 404 to GitHub-hosted runners for exactly those pages, while 26 other Razorpay pages pass. #### Root cause The response depends on where the request comes from. A 404 from this host is not evidence that the page is missing. #### Resolution A 404 from hosts known to answer by region is reported as unverified instead of broken (pull request #71). A 410, an unresolved host and a redirect to a "page not found" page still fail for every host. ```js const REGIONAL_HOSTS = new Set(["razorpay.com"]); // ... if (res.status === 404 && REGIONAL_HOSTS.has(new URL(url).hostname)) return { kind: "unverified", detail: "404, regional host" }; ``` #### Validation Actions run 40278468636 checked 937 links: 0 broken, 44 unverified. #### Remaining limitations A Razorpay page that really disappears and answers 404 would be reported as unverified, not broken, until it is checked from a browser. #### References - [Issue #69](https://github.com/ratomirjovanovic/PandaPayLens/issues/69) - [Pull request #70](https://github.com/ratomirjovanovic/PandaPayLens/pull/70) - [Pull request #71](https://github.com/ratomirjovanovic/PandaPayLens/pull/71) - [Actions run 40278468636](https://github.com/ratomirjovanovic/PandaPayLens/actions/runs/40278468636) ### A shared id prefix made Checkout.com a false alternative A record whose only Checkout.com trait was a pay_ id scored higher than a loosely matching record with documented fields, so every Razorpay payment listed Checkout.com as an alternative. URL: https://www.f4.rs/problems/shared-id-prefix-false-provider-match Status: resolved Project: PandaPayLens (https://www.f4.rs/work/pandapaylens) Occurred: 2026-10-25 #### Environment - Project: PandaPayLens - Runtime: TypeScript on Node.js 24 and in the browser - Component: Checkout.com adapter, detect() #### Symptoms Every Razorpay payment showed Checkout.com as an alternative provider with confidence 0.4. #### Expected An identifier prefix shared by two providers should not outrank documented structure. #### Observed A bare pay_ id scored 0.4, above a record with matching documented fields at 0.3. #### Root cause Razorpay payment entities also use pay_ ids. The Checkout.com detector gave a bare prefix match more weight than structural evidence. #### Resolution A bare `pay_` id now scores 0.3 when it has the documented 26-character form and 0.15 otherwise (pull request #59): ```ts const PAY_STRICT = /^pay_\w{26}$/; const PAY_LOOSE = /^pay_[A-Za-z0-9]{6,64}$/; ``` #### Validation A Razorpay test asserts every alternative is at most 0.15 with the full registry loaded. The Checkout.com detector was run over every other provider fixture and scored 0 on all of them. #### References - [Issue #58](https://github.com/ratomirjovanovic/PandaPayLens/issues/58) - [Pull request #59](https://github.com/ratomirjovanovic/PandaPayLens/pull/59) ### A refund delivered twice looked like two refunds Providers that send notifications without a delivery id defeated duplicate detection, so a repeated refund notification could be read as refunds exceeding the payment. URL: https://www.f4.rs/problems/duplicate-webhook-without-delivery-id Status: resolved Project: PandaPayLens (https://www.f4.rs/work/pandapaylens) Occurred: 2026-10-25 #### Environment - Project: PandaPayLens - Runtime: TypeScript on Node.js 24 and in the browser - Providers affected: dLocal, Paystack, PayU India, EBANX refund notifications and others #### Symptoms No duplicate was reported for identical repeated records from providers without a delivery id, and the amount mismatch rule could report that refunds exceed the payment when the same refund notification arrived twice. #### Expected A repeated delivery is recognized as a repeat, and amounts are not counted twice. #### Observed Each copy was treated as a separate event. #### Root cause Repeat detection used only the adapter's `deliveryKey`. When a provider sends no delivery id, there was no key, so identical records never matched. #### Resolution The engine now computes a `contentFingerprint` for every event: a 64-bit FNV-1a pair over the provider, format, event path and the record content serialized with sorted keys. The rules use one key for "the same observation": ```ts export function repeatKey(e: NormalizedEvent): string | undefined { if (e.deliveryKey) return `${e.provider}:key:${e.deliveryKey}`; if (e.contentFingerprint) return `${e.provider}:content:${e.contentFingerprint}`; return undefined; } ``` Keyed repeats are reported as confirmed and content repeats as possible. Adapters set `contentMayRepeat` where the provider documents that identical records can be separate events (Paymentwall partial refunds and reversals, EBANX refund and chargeback notifications). Copies that disagree are reported by the conflicting observations rule. #### Validation New tests in packages/rules/tests/content-duplicates.test.ts; nine adapter tests updated. Pull request #50 reports 4,581 tests passing. #### Remaining limitations Identical content does not prove a repeated delivery: two separate events with exactly the same fields look the same. The supplied data also cannot show whether the receiving application processed the event more than once. #### References - [Issue #49](https://github.com/ratomirjovanovic/PandaPayLens/issues/49) - [Pull request #50](https://github.com/ratomirjovanovic/PandaPayLens/pull/50) ### The timeline showed late payment steps first Events without a timestamp were sorted after every timed event, so a Paystack flow appeared with the webhook and verification before the PIN and OTP steps. URL: https://www.f4.rs/problems/untimed-events-sorted-to-the-end Status: resolved Project: PandaPayLens (https://www.f4.rs/work/pandapaylens) Occurred: 2026-10-25 #### Environment - Project: PandaPayLens - Runtime: TypeScript on Node.js 24 and in the browser - Providers affected: Paystack, PayU India and other providers with untimed API responses #### Symptoms A lifecycle of initialize, send_pin, send_otp, webhook, verify was displayed with webhook and verify first. #### Expected Events without their own time stay where they occurred relative to the timed events around them. #### Observed All untimed events were placed after the last timed event. #### Root cause `sortEvents` ordered timed events and appended every untimed event at the end, discarding the position the event had in the input. #### Resolution Untimed events are now anchored to the timed event that precedes them in the input; untimed events before any timed one lead (pull request #47): ```ts // An event without its own time stays right after the timed event that // precedes it in the input; untimed events before any timed one lead. for (const e of inInput) { if (!Number.isNaN(timeOf(e))) anchor = e; else if (anchor) after.set(anchor, [...(after.get(anchor) ?? []), e]); else leading.push(e); } return [...leading, ...timed.flatMap((e) => [e, ...(after.get(e) ?? [])])]; ``` #### Validation Thirteen adapter tests asserted the old order and were re-checked ("same events, same fields, only the order differs"). Two new core tests. Rule and scenario results were unchanged; 4,576 tests passed. #### References - [Issue #44](https://github.com/ratomirjovanovic/PandaPayLens/issues/44) - [Pull request #47](https://github.com/ratomirjovanovic/PandaPayLens/pull/47) ### Redaction regexes that ran in quadratic time on long text Two patterns used to hide email-like values in provider text backtracked from every position on long input without whitespace. Both were replaced with bounded, linear-time alternatives. URL: https://www.f4.rs/problems/linear-time-redaction-in-payment-text Status: resolved Project: PandaPayLens (https://www.f4.rs/work/pandapaylens) Occurred: 2026-10-25 #### Environment - Project: PandaPayLens - Runtime: TypeScript on Node.js 24 and in the browser - Detected by: CodeQL alert js/polynomial-redos #### Symptoms CodeQL code scanning raised alert #4 (js/polynomial-redos) on the scrub function of the PayU India adapter. A follow-up test of the core email pattern measured 18 to 26 seconds for 80,000 characters. #### Reproduction Pass a long string with no whitespace and no `@` character to the scrubber, for example 80,000 repeated letters. The PayU India scrubber used: ```ts text.replace(/\S*@\S*/g, "[redacted]") ``` The core email pattern had the same shape: an unbounded character class on both sides of `@`. #### Expected Scrubbing cost grows linearly with input length; pasted text of a few megabytes is processed in well under a second. #### Observed The regex engine retries the match from every starting position, so the work grows with the square of the input length. #### Root cause An unbounded `\S*` before `@` matches the entire remaining run of non-space characters from each starting position, fails to find `@`, and backtracks. On a run of length n this repeats n times, giving O(n²) work. Issue #40 describes it: "On a long string without whitespace and without `@`, the engine retries from every position, so the cost grows quadratically." #### Resolution The PayU India scrubber now bounds the input and redacts whole tokens instead of matching a pattern around `@` (pull request #41): ```ts // Bounded input and a token pass (not /\S*@\S*/) keep the cost linear. const t = v .slice(0, max * 4) .split(/(\s+)/) .map((token) => (token.includes("@") ? "[redacted]" : token)) .join("") ``` The core email pattern was given RFC 5321 length limits so each side of `@` is bounded (pull request #43): ```ts /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,253}[.][A-Za-z]{2,24}/g ``` #### Validation A test feeds a UPI id followed by a long whitespace-free run. Pull request #41 reports 100 PayU India tests passing; pull request #43 adds a linear-time test for the core pattern and reports 4,574 tests passing. #### Remaining limitations Whether CodeQL closed alert #4 after the merge was not verified for this record. Redaction remains best effort, as the project README states. #### References - [Issue #40](https://github.com/ratomirjovanovic/PandaPayLens/issues/40) - [Pull request #41](https://github.com/ratomirjovanovic/PandaPayLens/pull/41) - [Pull request #43](https://github.com/ratomirjovanovic/PandaPayLens/pull/43) ## Journal ### Twelve payment and webhook code reviews: the bugs that keep coming back In October 2026 I reviewed twelve pull requests in Stripe, PayPal, Razorpay, Adyen, Payload, Medusa, Better Auth, Svix, Standard Webhooks, n8n and Hyperswitch code. Most findings fall into five patterns. URL: https://www.f4.rs/journal/twelve-payment-and-webhook-code-reviews Published: 2026-10-26 On 25 and 26 October 2026 I reviewed twelve open pull requests in payment, webhook and checkout code that other people maintain. None of them are mine, and I am not a maintainer of any of these projects. Each review has its own write-up with the problem, the proposed change, what I found, and what happened next, linked below. > **Where things stand on 26 October 2026** > One author adopted every suggestion and I approved (paypal-js). One fixed part of the review and still has open points (razorpay-node). One fixed a small point and agreed to defer two others, which I turned into a follow-up issue (Payload). One pull request was closed by its author to be reworked (Better Auth). The other eight are open with no reply yet. None of the twelve has been merged. #### The five patterns ##### 1. A fix that quietly accepts more than before The most common finding. The intent of the change is right, but a conversion around it widens what the code accepts. - [razorpay-node](https://www.f4.rs/journal/razorpay-node-constant-time-webhook-signature-review): decoding the signature as hex before the constant-time compare started accepting upper-case, truncated and padded signatures. - [Standard Webhooks](https://www.f4.rs/journal/standard-webhooks-binary-payload-signing-review): accepting any typed array signs `Uint16Array` data as platform-endian bytes, and Python's `bytes(5)` turns a wrong type into five zero bytes. - [paypal-js](https://www.f4.rs/journal/paypal-js-card-fields-onapprove-types-review): `"POSSIBLE" | ... | string` collapses to `string`, and an optional `orderID` breaks every order integration. - [stripe-react-native](https://www.f4.rs/journal/stripe-react-native-shipping-details-review): a shipping type that still accepts `email`, which the native SDKs drop. ##### 2. Bytes and text are not the same thing HMACs authenticate bytes. Header names are case-insensitive. Values can be lists. - [Standard Webhooks](https://www.f4.rs/journal/standard-webhooks-binary-payload-signing-review): signing the UTF-8 decoding of a binary body. - [Svix](https://www.f4.rs/journal/svix-case-insensitive-webhook-headers-review): PSR-7 header values are arrays, and `intval(['1700000000'])` is `1`. - [stripe-node](https://www.f4.rs/journal/stripe-node-endpointfetch-response-headers-review): a plain object makes header lookups case-sensitive. ##### 3. Order, retries and time Webhooks arrive late, twice and out of order. State that is compared across events has to survive that. - [Better Auth](https://www.f4.rs/journal/better-auth-stripe-webhook-ordering-callbacks-review): a retried event can skip `onTrialEnd` because a later event already updated the stored row. - [Medusa](https://www.f4.rs/journal/medusa-refund-race-refunded-amount-review): a total that counts unconfirmed refunds is wrong whenever two refunds interleave. - [n8n](https://www.f4.rs/journal/n8n-mailchimp-webhook-signature-review): keeping only the last signature breaks key rotation; existing workflows never receive a secret. - [Adyen Web](https://www.f4.rs/journal/adyen-web-3ds2-duplicate-challenge-completion-review): a listener left on `window` completes the next payment's challenge. ##### 4. Tests that do not test what they claim - [razorpay-node](https://www.f4.rs/journal/razorpay-node-constant-time-webhook-signature-review): Mocha tests nested inside another test never run. - [paypal-js](https://www.f4.rs/journal/paypal-js-card-fields-onapprove-types-review): a runtime test cannot check a type definition. - [Svix](https://www.f4.rs/journal/svix-case-insensitive-webhook-headers-review) and [stripe-node](https://www.f4.rs/journal/stripe-node-endpointfetch-response-headers-review): fixtures that only use the convenient shape, so the bug cannot show up. ##### 5. The change around the change Descriptions, release notes, rollbacks and scope. - [Hyperswitch Web](https://www.f4.rs/journal/hyperswitch-web-postmessage-origin-validation-review): a description that documents behavior the code does not have, and a security fix bundled with behavior changes. - [Payload](https://www.f4.rs/journal/payload-ecommerce-stripe-metadata-limit-review): a change that is safe to deploy and unsafe to roll back while payments are in flight. #### How I review I read the change against the current code on the default branch, not only the diff, and I check claims before writing them: a regex, a PHP conversion, a Mocha behavior, a failed CI log. Where I could not confirm something, the review says so, and so does the write-up. I keep reviews specific: the line, the input that breaks, and the smallest change that fixes it. In one case the project's history shows that my suggestion had already been tried and rolled back in 2023; the Adyen write-up says so. #### All twelve 1. [razorpay-node: constant-time webhook signatures](https://www.f4.rs/journal/razorpay-node-constant-time-webhook-signature-review) 2. [paypal-js: typing onApprove per flow](https://www.f4.rs/journal/paypal-js-card-fields-onapprove-types-review) 3. [stripe-node: endpointFetch response headers](https://www.f4.rs/journal/stripe-node-endpointfetch-response-headers-review) 4. [stripe-react-native: shipping details and the API surface check](https://www.f4.rs/journal/stripe-react-native-shipping-details-review) 5. [Payload: Stripe metadata limit and a digest](https://www.f4.rs/journal/payload-ecommerce-stripe-metadata-limit-review) 6. [Better Auth: webhook ordering and lifecycle callbacks](https://www.f4.rs/journal/better-auth-stripe-webhook-ordering-callbacks-review) 7. [Medusa: the refund race](https://www.f4.rs/journal/medusa-refund-race-refunded-amount-review) 8. [Adyen Web: orphaned 3DS2 listeners](https://www.f4.rs/journal/adyen-web-3ds2-duplicate-challenge-completion-review) 9. [Standard Webhooks: binary payloads](https://www.f4.rs/journal/standard-webhooks-binary-payload-signing-review) 10. [Svix: case-insensitive headers](https://www.f4.rs/journal/svix-case-insensitive-webhook-headers-review) 11. [n8n: Mailchimp webhook signatures](https://www.f4.rs/journal/n8n-mailchimp-webhook-signature-review) 12. [Hyperswitch Web: postMessage origin checks](https://www.f4.rs/journal/hyperswitch-web-postmessage-origin-validation-review) The outcomes in each post are dated. I will update them when the pull requests move. #### References - [My public reviews on GitHub](https://github.com/pulls?q=is%3Apr+reviewed-by%3Aratomirjovanovic+-user%3Aratomirjovanovic) ### Stripe webhook ordering in Better Auth: how a retry can drop onTrialEnd Refetching the subscription is the right fix for Stripe snapshot events. Deriving lifecycle callbacks from the event while guarding them with the stored row meant a retried event could skip onTrialEnd, onTrialExpired and onSubscriptionCancel. URL: https://www.f4.rs/journal/better-auth-stripe-webhook-ordering-callbacks-review Published: 2026-10-26 > **At a glance** > Project: Better Auth, a TypeScript authentication framework, Stripe plugin. Pull request #12014 by a project collaborator. My role: outside reviewer. Status: the author closed the pull request without merging about two hours after the review, saying they would consolidate the improvements and revisit; no replacement existed on 26 October 2026. #### The problem A Stripe event's `data.object` is a snapshot rendered in the webhook endpoint's API version. The plugin's types follow the Stripe SDK's API version, which can be newer, so fields like billing periods could be read from a differently shaped object. Stripe's guidance for snapshot events is to fetch the current object before acting on it. #### The proposed fix Both subscription handlers now retrieve the subscription with the SDK, and a failed retrieval is deliberately not caught, so the webhook returns an error and Stripe retries the event: packages/stripe/src/hooks.ts: ```ts // The payload follows the webhook endpoint's API version, so refetch to get the SDK's shape. // A failed fetch is not caught here, so the webhook fails and Stripe retries the event. const stripeSubscriptionUpdated = await options.stripeClient.subscriptions.retrieve(eventSubscription.id); ``` Because a refetched object always shows the latest state, lifecycle callbacks were moved to the event's own `previous_attributes`, and guarded with the stored row so a redelivered event does not fire twice: ```ts const isTrialEnded = previousSubscription.status === "trialing" && eventSubscription.status === "active"; // ... if (isTrialEnded && subscription.status === "trialing") { /* onTrialEnd */ } ``` #### What I found in review The refetch is right. The guard is where it breaks. It combines two different points in time: `isTrialEnded` describes this event's transition, while `subscription` is the stored row, which earlier events already wrote from the refetched, latest state. Stripe does not guarantee delivery order, and this pull request makes reordering more likely, because a transient refetch failure now sends an event to a retry. The sequence: 1. E1: status `trialing` to `active`, with `previous_attributes: { status: "trialing" }`. 2. E2, shortly after: an unrelated change, such as metadata or quantity. 3. E1's refetch fails transiently; the route returns an error and Stripe schedules a retry. 4. E2 is processed: no transition in its payload, but the refetch says `active`, so the stored row goes from `trialing` to `active` and no callback runs. 5. E1 is retried: `isTrialEnded` is true, but the stored row is already `active`, so `onTrialEnd` is skipped. `onTrialEnd` never runs. Before the pull request, step 4 would have fired it, because the old check compared the stored row with the current state. The same sequence drops `onSubscriptionCancel` and `onTrialExpired`. The fix is to fire on whichever event first sees the change, while the stored row still shows the old state: ```ts const shouldFireTrialEnd = subscription.status === "trialing" && (isTrialEnded || stripeSubscriptionUpdated.status === "active"); const shouldFireCancel = !isPendingCancel(subscription) && (isNewCancellation || (stripeSubscriptionUpdated.status === "active" && isStripePendingCancel(stripeSubscriptionUpdated))); ``` And a test that reproduces the order: a stored `trialing` row, `retrieve` mocked to return `active`, E2 delivered before E1, and `onTrialEnd` expected exactly once. This does not cover duplicate callbacks on redelivery, which earlier automated reviews raised and which need deduplication by event id; it covers the dropped ones. > **The rule underneath** > Sync stored state from the refetched object: that is idempotent and order-independent. Fire one-shot side effects when the stored state moves from old to new, whichever event causes it, and deduplicate redeliveries by event id. #### What happened next > **Outcome** > The author thanked me, wrote that the pull request had been opened temporarily and that they would consolidate the improvements and revisit it, and closed it unmerged. All 41 checks had passed. No replacement pull request existed on 26 October 2026, so I cannot say whether the suggestion will be adopted. #### Takeaways - "What changed in this event" and "what the object looks like now" are different truths. Use each for what it is good at. - Every new retry path creates new orderings. Re-check any logic that compares an event with stored state. - With at-least-once, unordered delivery, the safe trigger for a side effect is a stored state transition, not an event. #### References - [Better Auth pull request #12014](https://github.com/better-auth/better-auth/pull/12014) - [My review](https://github.com/better-auth/better-auth/pull/12014#pullrequestreview-5643318008) - [Inline: the guard mixes two points in time](https://github.com/better-auth/better-auth/pull/12014#discussion_r4370936582) - [Author comment when closing](https://github.com/better-auth/better-auth/pull/12014#issuecomment-6399859690) - [Stripe: event ordering and retries](https://docs.stripe.com/webhooks#event-ordering) ### Payload's Stripe adapter and the 500-character metadata limit: keep the check, store a digest Payload ecommerce put the whole cart into Stripe PaymentIntent metadata, so checkout failed at about eight items. Removing it fixed checkout and removed the only item-drift check; a 64-character hash keeps both. URL: https://www.f4.rs/journal/payload-ecommerce-stripe-metadata-limit-review Published: 2026-10-26 > **At a glance** > Project: Payload CMS, plugin-ecommerce, Stripe adapter. Pull request #19024 by lazerg, fixing issue #19017. My role: outside reviewer with four inline notes. Status on 26 October 2026: one note addressed in a commit, two acknowledged and deferred, and I opened issue #19055 to track them. The pull request is open, CI is green, and no maintainer has reviewed it yet. #### The problem Issue #19017, verbatim from the error: ```text Error: Metadata values can have up to 500 characters, but you passed in a value that is 1090 characters. ``` The Stripe adapter stored the whole cart in the PaymentIntent metadata as `cartItemsSnapshot: JSON.stringify(flattenedCart)`. Stripe limits each metadata value to 500 characters. At roughly 60 characters per item, checkout broke at about eight items, and the customer only saw "Error initiating payment". The snapshot was not just carried along. `confirmOrder` read it back, `validateSettlement` compared it with the stored transaction items, and the order was created from it. #### The proposed fix `initiatePayment` already writes the same cart to the transaction, and `confirmOrder` already loads that transaction. So the pull request drops the snapshot and builds the order from `transaction.items`: packages/plugin-ecommerce/src/payments/adapters/stripe/initiatePayment.ts: ```diff metadata: { cartID: cart.id, - cartItemsSnapshot: JSON.stringify(flattenedCart), shippingAddress: shippingAddressAsString, }, ``` A new test checks out a 30-item cart and asserts that every metadata value is within 500 characters. Using your own database row as the source of truth, instead of a round trip through a payment provider, is the right direction. #### What I found in review ##### The fix also removed the drift check `validateSettlement` used to fail if the items about to be fulfilled differed from the items that were priced when the PaymentIntent was created. The pull request deletes that comparison. The amount check still holds and the transactions collection is admin-only by default, so this is defense in depth rather than an open hole. But the check does not need the items, only a fingerprint of them: ```ts metadata: { cartID: cart.id, cartItemsHash: createHash('sha256') .update(stableSerialize(normalizeCartItems(flattenedCart))) .digest('hex'), // 64 characters shippingAddress: shippingAddressAsString, } ``` `validateSettlement` would hash `normalizeCartItems(transaction.items)` the same way and compare. Metadata can only be written with the secret key, so a plain SHA-256 is enough; an HMAC would only matter if an untrusted party could write the stored value. The existing normalization (relationship objects versus ids, key order, null variants) and its tests stay useful. ##### A rollback would fail charged payments PaymentIntents live across deployments. Old intents carry `cartItemsSnapshot`, which the new code ignores, so upgrading is safe. But intents created after the upgrade have no snapshot. If someone deploys this and then rolls back, the old `confirmOrder` rejects every in-flight payment with "cartItemsSnapshot metadata is missing or invalid", after the customer has been charged. Not a blocker, but worth a line in the release notes. ##### shippingAddress is the next 500-character bug The new test loops over every metadata value, but the fixture's address is `{ city: 'Test City' }`. In practice the value is the JSON of whatever address object the client sends; a saved address document can include `id`, `customer`, `createdAt` and `updatedAt`, and a long company name pushes it over the limit. Storing the address on the transaction, like `billingAddress`, would remove the last unbounded value. ##### A call that looks like dead code `normalizeCartItems(transaction.items)` was now called only for its throw-on-invalid side effect, with the result discarded. Without a comment, the next reader deletes it. #### What happened next The author replied to every point within the hour: > **Addressed** > Commit d52f2fe adds the comment: "Called only to throw on missing or malformed transaction items, the result is not needed." > **Deferred, by agreement** > The author preferred to keep the pull request minimal so the checkout fix can ship alone, agreed with the rollback risk and left the release note to the maintainers, and noted that moving the address needs a schema change. That is a reasonable call. So the two points would not get lost, I opened issue #19055 with the problem, the proposal and acceptance criteria, including that intents without the hash skip the comparison so the upgrade stays safe. > **Status on 26 October 2026** > No maintainer review yet; 290 checks pass. A competing pull request, #19025, is open for the same issue. #### Takeaways - Payment provider metadata is a reference channel with hard size limits, not storage. Keep the data in your database and keep a key or a fingerprint at the provider. - When removing data that a check depended on, ask whether the check needs the data or only its identity. A digest is usually enough. - Any field that crosses a deploy boundary needs a compatibility story in both directions. - When a contributor wants to keep a fix small, help them do that, and write the rest down as an issue. #### References - [Payload pull request #19024](https://github.com/payloadcms/payload/pull/19024) - [Issue #19017: Stripe adapter exceeds 500-char metadata limit](https://github.com/payloadcms/payload/issues/19017) - [My review](https://github.com/payloadcms/payload/pull/19024#pullrequestreview-5643272287) - [Inline: digest instead of snapshot](https://github.com/payloadcms/payload/pull/19024#discussion_r4370903116) - [Inline: rollback after upgrade](https://github.com/payloadcms/payload/pull/19024#discussion_r4370903124) - [Inline: shippingAddress is still unbounded](https://github.com/payloadcms/payload/pull/19024#discussion_r4370903128) - [Commit d52f2fe: comment added after review](https://github.com/payloadcms/payload/commit/d52f2fe1d051da56b20f440cdb9548a10d56c408) - [Follow-up issue #19055 I opened](https://github.com/payloadcms/payload/issues/19055) - [Stripe: metadata limits](https://docs.stripe.com/metadata) ### HMAC signs bytes, not text: binary webhook payloads in Standard Webhooks The JavaScript and Python Standard Webhooks libraries decoded the body as text before signing it, so gzip, protobuf or image payloads could not be verified. The fix is right; the review is about the inputs it now accepts silently. URL: https://www.f4.rs/journal/standard-webhooks-binary-payload-signing-review Published: 2026-10-26 > **At a glance** > Project: Standard Webhooks, an open specification for signing webhooks, with reference libraries in many languages. Pull request #348 by mkherlakian. My role: outside reviewer; I agreed with the fix and left three notes and two coordination points. Status on 26 October 2026: open, waiting for a maintainer, no reply yet. #### The problem A Standard Webhooks signature is `v1,` followed by the base64 HMAC-SHA256 of `{msg_id}.{timestamp}.{payload}`. The payload is whatever bytes were sent. The JavaScript and Python libraries turned it into text first: - **JavaScript:** `sign()`, which `verify()` uses, called `payload.toString()` on a `Buffer`. That decodes UTF-8 and replaces every invalid sequence with U+FFFD, so the HMAC covered different bytes than were sent. - **Python:** `verify()` called `data.decode()` on bytes, which raises `UnicodeDecodeError` for a non-UTF-8 body, even when the signature is wrong, instead of a verification error. `sign()` only expected `str`; given bytes, it interpolated their repr (`b'...'`) and returned a wrong signature without complaint. So a correctly signed gzip, protobuf or image body could not be verified. The Go library already signed the bytes as given. #### The proposed fix Both libraries now build `prefix || payload bytes` instead of interpolating the payload into a string: libraries/javascript/src/index.ts: ```ts const prefix = new TextEncoder().encode(`${msgId}.${timestampNumber}.`); const toSign = new Uint8Array(prefix.length + bytes.length); toSign.set(prefix); toSign.set(bytes, prefix.length); ``` libraries/python/standardwebhooks/webhooks.py: ```python to_sign = f"{msg_id}.{timestamp_str}.".encode() + _payload_bytes(data) ``` The tests include a known-answer vector over all 256 byte values whose expected signature, according to the pull request, comes from the Go library. A test vector produced by an independent implementation is the right kind of test: it cannot simply mirror the code under test. #### What I found in review The change is correct. String input and the same text as UTF-8 bytes sign identically, so existing users see no difference. My notes were about inputs that are now accepted without an error. ##### Any typed array is now "bytes" The JavaScript helper accepts anything for which `ArrayBuffer.isView` is true. It uses `isView` instead of `instanceof Uint8Array` so that arrays from another realm (an iframe, a `vm` context) still work, which is a good reason. But `isView` is also true for `DataView`, `Uint16Array` and `Float64Array`, and those would be signed as their raw platform-endian bytes. A signature that only works on one machine is worse than an error. Restricting to byte views keeps the cross-realm support: ```ts ArrayBuffer.isView(payload) && (payload as any).BYTES_PER_ELEMENT === 1 ``` ##### Python's bytes() is not a type check `bytes(5)` is five zero bytes and `bytes([1, 2])` is `b"\x01\x02"`. Passing an `int` or an already-parsed list would now produce a signature over unrelated bytes, while the JavaScript library throws. I suggested an explicit check: ```python if isinstance(data, str): return data.encode() if isinstance(data, (bytes, bytearray, memoryview)): return bytes(data) raise TypeError("Expected payload to be of type str or bytes") ``` ##### A BOM now parses On the JSON path, `new TextDecoder().decode(bytes)` strips a leading UTF-8 byte order mark; the old `Buffer.toString()` kept it, and `JSON.parse` then threw. That is probably intended and matches Python's `json.loads(bytes)`, so the two libraries now agree. It deserves a test or a sentence in the description so the behavior is pinned. ##### Coordination Pull request #343 also changes `verify()`, and its version still calls `payload.toString()` after the signature loop. Whichever lands second has to keep the byte-based handling, or a mechanical rebase brings the lossy decode back. I also suggested putting the 0-255 vector somewhere shared, such as the spec folder, so the Rust, C#, Ruby and PHP follow-ups reuse one vector instead of copying it. > **Checked locally** > `ArrayBuffer.isView` is true for `DataView` and `Uint16Array`; `TextDecoder` drops the BOM while `Buffer.toString()` keeps it; Python `bytes(5)` and `bytes([1, 2])` behave as described. #### What happened next > **Status on 26 October 2026** > No reply and no new commits. No CI checks are reported on the branch, so the passing test counts in the description are the author's. #### Takeaways - An HMAC authenticates bytes. Any decode and re-encode between the wire and the HMAC changes the input. - When a function starts accepting a wider type, check what it does with the inputs nobody intended to pass. - Cross-language known-answer vectors belong next to the specification, not in one library's test file. #### References - [standard-webhooks pull request #348](https://github.com/standard-webhooks/standard-webhooks/pull/348) - [Related pull request #343](https://github.com/standard-webhooks/standard-webhooks/pull/343) - [My review](https://github.com/standard-webhooks/standard-webhooks/pull/348#pullrequestreview-5643272422) - [Inline: ArrayBuffer.isView accepts any view](https://github.com/standard-webhooks/standard-webhooks/pull/348#discussion_r4370903241) - [Inline: TextDecoder strips a BOM](https://github.com/standard-webhooks/standard-webhooks/pull/348#discussion_r4370903242) - [Inline: Python bytes() is not a type check](https://github.com/standard-webhooks/standard-webhooks/pull/348#discussion_r4370903245) - [Standard Webhooks specification](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) ### Constant-time webhook signatures in razorpay-node: when the fix accepts more than the bug did A pull request replaced === with crypto.timingSafeEqual in razorpay-node. Decoding the signature as hex made the check accept upper-case, truncated and padded signatures, and the new tests never ran. URL: https://www.f4.rs/journal/razorpay-node-constant-time-webhook-signature-review Published: 2026-10-26 > **At a glance** > Project: razorpay-node, Razorpay's server SDK for Node.js. Pull request #495 by gopi-701, closing issue #479. My role: outside reviewer, not a maintainer. Status on 26 October 2026: open, not merged; two of my points were fixed, two are still open. #### The problem Razorpay signs every webhook with HMAC-SHA256. The SDK's `validateWebhookSignature(body, signature, secret)` recomputes the HMAC and compares it with the signature from the request header. Issue #479 points at the comparison: lib/utils/razorpay-utils.js: ```js var expectedSignature = crypto.createHmac('sha256', secret).update(body).digest('hex'); return expectedSignature === signature; ``` A string comparison with `===` can stop at the first byte that differs, so in principle the response time leaks how much of a guessed signature is correct (CWE-208). Whether that is measurable over a network is debatable, but comparing MACs in constant time is cheap and is the standard practice. The same function also backs `validatePaymentVerification`, which ends with `return validateWebhookSignature(payload, signature, secret)`, so payment signature checks share the comparison. #### The proposed fix The first version of the pull request decoded both values from hex and compared the bytes with Node's constant-time function: ```js // Use constant-time comparison to prevent timing attacks (CWE-208). const a = Buffer.from(expectedSignature, 'hex'); const b = Buffer.from(signature, 'hex'); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); ``` `timingSafeEqual` is the right tool. The question is what the code feeds into it. #### What I found in review ##### Hex decoding is lenient `Buffer.from(str, 'hex')` does not validate its input. It is case-insensitive, it stops at the first character that is not hex, and it silently drops a trailing odd nibble. Different strings therefore decode to the same bytes, and the check started accepting values that `===` had rejected. From the review: > With this change all of these now return `true` for a correct HMAC, where the old `===` returned `false`: the signature in upper case; the signature followed by any non-hex text, e.g. `sig + "zz"`; the signature followed by one extra hex character, e.g. `sig + "f"`. None of these let anyone forge a signature, but a verification function shouldn't start accepting values it rejected before. There was a second behavior change. The input guard `isDefined()` only rejects `undefined`, so a `null` signature reached `Buffer.from` and threw `ERR_INVALID_ARG_TYPE` where the old code returned `false`. The smallest fix keeps the exact semantics of `===` by comparing the hex strings as bytes: ```js const a = Buffer.from(expectedSignature, 'utf8'); const b = Buffer.from(String(signature), 'utf8'); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); ``` The early length check only leaks the length, and the length of a SHA-256 hex digest is public anyway: 64 characters. ##### The new tests never ran The two new `it(...)` blocks were added inside the callback of the existing `it('validateWebhookSignature', ...)`, because that test's closing `});` had moved down. Mocha collects tests before it runs them; an `it` registered from inside a running test is never executed and nothing reports it. I checked it with Mocha 9: an outer test containing two failing inner tests passes, and the inner tests never appear in the output. So the PR's "379 passing" did not include either new test. > **What I suggested** > Close the existing test after its assertion, test `correctSignature + "zz"` and `correctSignature.toUpperCase()` explicitly, and either drop the first new test (it only repeats the old one with a different wrong signature) or spy on `crypto.timingSafeEqual` to show that the constant-time path is actually used. I also pointed out that pull requests #465, #469 and #473 were already open for the same issue, so the maintainers may want to settle on one. #### What happened next The author pushed several commits. One of them contained unresolved merge conflict markers (`<<<<<<< HEAD`, `=======`, `>>>>>>> HEAD@{1}`) in both the library and the test file, which means the module no longer parsed, and it also changed an unrelated `fail_existing` type in `lib/types/customers.d.ts`. I flagged both, and noted that the regular expression in that version, `/^[0-9a-fA-F]+$/`, still accepted an upper-case signature. The current head validates the input before decoding: ```js if (signature.length !== expectedSignature.length || !/^[0-9a-f]+$/.test(signature)) { return false; } ``` > **Fixed in the pull request** > The conflict markers are gone, and the lower-case hex and length checks restore the old accepted set without the utf8 comparison. > **Still open on 26 October 2026** > The author's comment says the tests were moved to the top level, but the diff still nests them, so they still do not run. The unrelated `fail_existing` change is still in the diff. A new `typeof secret !== 'string'` guard now throws for a `Buffer` secret, which `createHmac` accepts and which worked before. No maintainer has commented yet. #### Why it matters Security fixes get less scrutiny than they deserve because the intent is obviously good. Here the intent was right and the core call was right, but the surrounding conversion widened what the function accepts, and the test that was supposed to prove the fix was silently skipped. Neither shows up in a green CI run. #### Takeaways - When you harden a comparison, keep the accepted input set identical. Compare the exact bytes you used to compare, or validate the format first. - `Buffer.from(value, 'hex')` is a decoder, not a validator. - In Mocha, a nested `it` is a test that does not exist. Count the tests the reporter prints, not the ones you wrote. - Read the diff, not the summary comment. "Addressed all of your points" and the diff disagreed here. #### References - [razorpay-node pull request #495](https://github.com/razorpay/razorpay-node/pull/495) - [Issue #479: timing attack in validateWebhookSignature](https://github.com/razorpay/razorpay-node/issues/479) - [My review (changes requested)](https://github.com/razorpay/razorpay-node/pull/495#pullrequestreview-5643317914) - [Inline comment: lenient hex decoding](https://github.com/razorpay/razorpay-node/pull/495#discussion_r4370936516) - [Inline comment: nested Mocha tests](https://github.com/razorpay/razorpay-node/pull/495#discussion_r4370936520) - [Follow-up: merge conflict markers](https://github.com/razorpay/razorpay-node/pull/495#issuecomment-6400834119) - [Follow-up: what is still open](https://github.com/razorpay/razorpay-node/pull/495#issuecomment-6409641472) - [Node.js crypto.timingSafeEqual](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b) ### Typing PayPal Card Fields onApprove per flow instead of making orderID optional A paypal-js pull request made orderID optional to support vault flows, a breaking type change for every order integration. Typing onApprove per branch of an existing union fixed it, and the author adopted the suggestion. URL: https://www.f4.rs/journal/paypal-js-card-fields-onapprove-types-review Published: 2026-10-26 > **At a glance** > Project: paypal-js, PayPal's JavaScript SDK loader and TypeScript definitions. Pull request #1086 by reginaldalfret, for issue #652. My role: outside reviewer. Status on 26 October 2026: the author adopted every suggestion in commit 0694868 and I approved; the pull request is open and still needs a maintainer review. #### The problem PayPal Card Fields support two flows. In the order flow the integration passes `createOrder`, and `onApprove` receives an `orderID`. In the vault-without-purchase flow it passes `createVaultSetupToken`, and `onApprove` receives a `vaultSetupToken` instead. The type definitions only described the first case: packages/paypal-js/types/components/card-fields.d.ts: ```ts export type CardFieldsOnApproveData = { orderID: string; liabilityShift?: string; }; ``` Issue #652 asked for `vaultSetupToken` and for the known `liabilityShift` values, which users were overriding by hand. #### The proposed fix ```diff export type CardFieldsOnApproveData = { - orderID: string; - liabilityShift?: string; + orderID?: string; + liabilityShift?: "POSSIBLE" | "YES" | "NO" | "UNKNOWN" | string; + vaultSetupToken?: string | null; }; ``` It adds the missing fields, but it changes two things for everyone. #### What I found in review ##### Optional orderID breaks every order integration With `strictNullChecks`, `orderID?: string` is `string | undefined`. Code that has always compiled stops compiling, even though `orderID` is always present in the order flow: ```ts captureOrder(data.orderID) // where captureOrder(id: string) // error: Argument of type 'string | undefined' is not assignable to parameter of type 'string' ``` That is a breaking change shipped in a `.d.ts` file. The types already had what was needed: `PayPalCardFieldsComponentOptions` is a union of `PayPalCardFieldsComponentCreateOrder` and `PayPalCardFieldsComponentCreateVaultSetupToken`, where each branch sets the other's function to `never`. TypeScript picks the branch from the key that is present, and a contextually typed callback gets that branch's parameter type. So `onApprove` can be typed per branch: ```ts export type CardFieldsOnApproveData = { orderID: string; liabilityShift?: CardFieldsLiabilityShift; }; export type CardFieldsVaultOnApproveData = { vaultSetupToken: string; liabilityShift?: CardFieldsLiabilityShift; }; ``` Moving `onApprove` from the shared base interface into the two branches keeps `orderID` required where it always exists and gives vault integrations a required, typed token. ##### A literal union that collapses to string `"POSSIBLE" | "YES" | "NO" | "UNKNOWN" | string` simplifies to `string`, because every literal is already a `string`. The literals disappear: no narrowing and no editor completions. The usual way to suggest known values while accepting new ones is to stop the union from collapsing: ```ts export type CardFieldsLiabilityShift = "POSSIBLE" | "YES" | "NO" | "UNKNOWN" | (string & {}); ``` `string & {}` still accepts any string, but TypeScript does not merge the literals into it, so they stay visible. ##### Tests that test the type The new test assigned values and read them back, which passes with the old type too. For type definitions the test has to fail at type-check time: `expectTypeOf` assertions, and `@ts-expect-error` on the shape that must not compile, such as reading `vaultSetupToken` in a `createOrder` configuration. I also noted that the repository asks for a changeset. #### What happened next The author replied the next morning and pushed commit 0694868. The diff matches each point: ```ts onApprove: (data) => { expectTypeOf(data).toEqualTypeOf(); expectTypeOf(data.orderID).toBeString(); // @ts-expect-error: vaultSetupToken does not exist on CardFieldsOnApproveData in createOrder config const token = data.vaultSetupToken; }, ``` > **Adopted** > `orderID` stays required in the order flow, the vault flow has `CardFieldsVaultOnApproveData`, `onApprove` is typed per branch, the union keeps its completions, the tests pin the types, and a changeset was added. I approved the revised version. > **Not merged yet** > My approval does not count toward the repository's required reviews. No PayPal maintainer has commented, and no CI checks are reported on the branch. The pull request description now credits "maintainer feedback"; the feedback came from me, and I am not a maintainer. #### Takeaways - In a type definition package, making a field optional is a breaking change. Look for an existing union before widening a shared type. - `"a" | "b" | string` is just `string`. Use `(string & {})` when you want suggestions without a closed set. - A runtime test cannot test a type. `expectTypeOf` and `@ts-expect-error` can. #### References - [paypal-js pull request #1086](https://github.com/paypal/paypal-js/pull/1086) - [Issue #652: CardFields onApprove type incorrect](https://github.com/paypal/paypal-js/issues/652) - [My first review](https://github.com/paypal/paypal-js/pull/1086#pullrequestreview-5643318187) - [Inline comment: optional orderID](https://github.com/paypal/paypal-js/pull/1086#discussion_r4370936751) - [Inline comment: union collapses to string](https://github.com/paypal/paypal-js/pull/1086#discussion_r4370936755) - [Commit 0694868 with the changes](https://github.com/paypal/paypal-js/commit/0694868527eb8d31e84dde5f8cd2921d5027d87d) - [My approval](https://github.com/paypal/paypal-js/pull/1086#pullrequestreview-5649435675) ### stripe-node endpointFetch headers: passing them through is not the same as using them A stripe-node change stops the endpointFetch HTTP client from discarding response headers. Two follow-ups: retry-after does not affect backoff, and a plain object makes header lookups case-sensitive. URL: https://www.f4.rs/journal/stripe-node-endpointfetch-response-headers-review Published: 2026-10-26 > **At a glance** > Project: stripe-node, Stripe's official Node.js SDK. Pull request #2848 by a Stripe engineer. My role: outside reviewer with two notes. Status on 26 October 2026: open, all CI checks pass, no reply yet. #### The problem stripe-node sends requests through pluggable HTTP clients. `EndpointFetchHttpClient` is used when the SDK runs inside Stripe's extensibility runtimes, which expose an `endpointFetch` function instead of `fetch`. Its response wrapper threw the headers away: src/net/EndpointFetchHttpClient.ts: ```ts constructor(res: EndpointFetchResponse) { super(res.status, {}); this._res = res; } ``` `RequestSender` reads several response headers: the request id, `stripe-should-retry`, `stripe-account`, `idempotency-key`, `stripe-notice`. With an empty object, all of them were missing in these runtimes. #### The proposed fix ```diff - super(res.status, {}); + super(res.status, res.headers ?? {}); ``` The error path gets the same treatment, and the changelog says the change preserves "response metadata and retry signals". A small, useful change. #### What I found in review I checked each claim against `RequestSender.ts` on master. ##### retry-after reaches the error, not the backoff `_shouldRetry` reads `stripe-should-retry` from the response headers, so that signal now works. The delay is different: `_getSleepTimeInMS` computes exponential backoff with jitter from the configured initial and maximum delays and never looks at `retry-after`. With this change, `retry-after` shows up in `error.headers` but does not change how long the SDK waits. The changelog wording invites the opposite reading, so I suggested adjusting it. ##### Header names are case-insensitive; a plain object is not HTTP field names are case-insensitive (RFC 9110). The other clients normalize them for free: Node's `http` module lowercases incoming header names, and `FetchHttpClient` builds its object from a `Headers` instance, which iterates in lower case. `endpointFetch` returns a plain record. From the inline comment: > If the runtime ever returns `Request-Id` or `Stripe-Should-Retry`, every lookup misses without an error. Lowercasing the keys once here (and for the rejection path) would make that independent of the runtime, and a test with mixed-case names would pin it. A normalization at the adapter boundary is a few lines: ```ts const lowerCaseKeys = (headers: Record = {}) => Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])) ``` The test fixtures in the pull request use only lower-case names, which is why they pass either way. > **What is not known** > Whether any real endpointFetch runtime returns mixed-case names. The point is that the adapter should not depend on it, because a miss is silent. #### What happened next > **Status on 26 October 2026** > No replies and no new commits. CI is green on Node 18, 20, 22 and 24, and the branch is behind master. #### Takeaways - An adapter that converts a foreign response into your internal shape is the place to normalize it. Every consumer after it can then assume one convention. - A plain object is not a `Headers` object. Case-insensitive lookup is a property of the type, not of the data. - Changelogs are part of the interface. "Retry signals" means something specific to whoever is debugging a 429 at night. #### References - [stripe-node pull request #2848](https://github.com/stripe/stripe-node/pull/2848) - [My review](https://github.com/stripe/stripe-node/pull/2848#pullrequestreview-5644215898) - [Inline comment: case-sensitive header lookups](https://github.com/stripe/stripe-node/pull/2848#discussion_r4371594339) - [RequestSender.ts on master](https://github.com/stripe/stripe-node/blob/master/src/RequestSender.ts) - [RFC 9110: field names are case-insensitive](https://www.rfc-editor.org/rfc/rfc9110#name-field-names) ### stripe-react-native shipping details: unblocking a failing API surface check A fix for missing carrier, phone and tracking number in stripe-react-native shipping details was stuck on a failing CI check. The remaining diff was not in the pull request at all, and the type still accepted a field that the native SDKs drop. URL: https://www.f4.rs/journal/stripe-react-native-shipping-details-review Published: 2026-10-26 > **At a glance** > Project: stripe-react-native, the React Native wrapper around Stripe's Android and iOS SDKs. Pull request #2752 by reginaldalfret, for issue #1959. My role: outside reviewer, trying to unblock a failing check. Status on 26 October 2026: open; the API surface check still fails and there has been no reply. #### The problem Issue #1959, open since May 2025: when an app passes `shippingDetails` to `confirmPayment`, the name and address reach Stripe, but `carrier` and `phone` do not. In a React Native bridge, the native side only reads the fields it maps explicitly; everything else is ignored without an error. Both native mappers built the shipping parameters from `name` and `address` only, and on the TypeScript side `ShippingDetails` was simply `BillingDetails`, which has no `carrier` or `trackingNumber` at all. #### The proposed fix The pull request maps the three fields on both platforms and widens the type: android/.../utils/Mappers.kt: ```kotlin return ConfirmPaymentIntentParams.Shipping( name = getValOr(shippingDetails, "name") ?: "", address = address, carrier = getValOr(shippingDetails, "carrier"), phone = getValOr(shippingDetails, "phone"), trackingNumber = getValOr(shippingDetails, "trackingNumber"), ) ``` src/types/PaymentMethod.ts: ```ts export type ShippingDetails = BillingDetails & { carrier?: string; trackingNumber?: string; }; ``` A maintainer had approved it, then asked for the build to be fixed before merging, and a day later noted that "the same tasks continue to fail". #### What I found in review ##### The failing check was not about this change The repository runs API Extractor, which generates a report of the public TypeScript surface; CI fails when the committed report differs from the regenerated one. I read the failed job log. The only remaining difference was one removed `// (undocumented)` comment above `style?: StyleProp` in the platform pay button props, around line 3568 of the report. The new `ShippingDetails` entry already matched. That kind of drift comes from `main`, not from the pull request, so rebasing and regenerating the report (`yarn api-extractor run --local`) should clear it. ##### The type is still wider than the native objects `BillingDetails` includes `email`. Stripe's shipping object has no email, and neither do `ConfirmPaymentIntentParams.Shipping` on Android nor `STPPaymentIntentShippingDetailsParams` on iOS. So an app can pass `shippingDetails.email`, see no error, and lose it. That is the same class of bug the pull request fixes, one field over. ```ts export type ShippingDetails = Omit & { carrier?: string; trackingNumber?: string; }; ``` With `Omit`, an object literal containing `email` becomes a compile error. I marked it as arguably out of scope. #### What happened next > **Status on 26 October 2026** > No reply and no new commits since 20 October. The API surface check and the Bitrise pipeline still fail. I did not look into the Bitrise failure. #### Takeaways - When CI fails on a generated report, read the diff in the log before changing code. Here the change was correct and the report was stale. - Across a native bridge, the TypeScript type is the only guard. If it is wider than what the native side reads, data disappears silently. - Small, specific help ("the remaining difference is this one line, regenerate it this way") is often what moves a stuck contribution. #### References - [stripe-react-native pull request #2752](https://github.com/stripe/stripe-react-native/pull/2752) - [Issue #1959: shipping details missing carrier and phone](https://github.com/stripe/stripe-react-native/issues/1959) - [My review](https://github.com/stripe/stripe-react-native/pull/2752#pullrequestreview-5644216047) - [Maintainer comment: build still failing](https://github.com/stripe/stripe-react-native/pull/2752#issuecomment-6291675182) - [Stripe API: PaymentIntent shipping](https://docs.stripe.com/api/payment_intents/object#payment_intent_object-shipping) ### Case-insensitive webhook headers in the Svix PHP and Ruby SDKs, and the PSR-7 array trap A Svix fix lets verify() find Svix-Id as well as svix-id. With PSR-7 headers the keys now match but the values are arrays, and intval turns the timestamp into 1. URL: https://www.f4.rs/journal/svix-case-insensitive-webhook-headers-review Published: 2026-10-26 > **At a glance** > Project: svix-webhooks, the Svix monorepo with SDKs for verifying webhooks. Pull request #2732 by Tyagiquamar, fixing issue #1013. My role: outside reviewer with two inline points. Status on 26 October 2026: open, CI green on PHP 8.3 to 8.5 and Ruby 3.2 to 4.0, no reply yet. #### The problem Svix sends `svix-id`, `svix-timestamp` and `svix-signature` (or the unbranded `webhook-*` names). The PHP and Ruby SDKs looked them up with exact lower-case keys: ```php isset($headers['svix-id']) ``` HTTP field names are case-insensitive (RFC 9110), and many frameworks hand headers to application code re-cased, for example as `Svix-Id`. Such a caller got "Missing required headers" although every header was there. Issue #1013, open since 2023, asked for a case-insensitive fallback that only runs when the fast lower-case lookup fails. #### The proposed fix PHP lowercases the keys only on the fallback path and then repeats the same branded-or-unbranded check: php/src/Webhook.php: ```php if (is_array($headers)) { $lowerHeaders = array_change_key_case($headers, CASE_LOWER); } elseif (is_iterable($headers)) { $lowerHeaders = []; foreach ($headers as $k => $v) { $lowerHeaders[strtolower($k)] = $v; } } ``` Ruby does the same with `transform_keys { |k| k.to_s.downcase }`. Both have tests with mixed-case names. #### What I found in review ##### PSR-7 headers: right keys, wrong value type A common way to call the PHP SDK is with PSR-7 headers: `$wh->verify($body, $request->getHeaders())`. In that shape the keys keep their original case, and every value is an array of strings, because a header can appear more than once. Before the pull request this input failed clearly with "Missing required headers". After it, the keys match, but the values are arrays: ```php $timestamp = intval(['1700000000'], 10); // int(1) if ($timestamp < ($now - $this->tolerance)) { throw new Exception\WebhookVerificationException("Message timestamp too old"); } ``` `intval` on a non-empty array returns `1`, so the caller now sees "Message timestamp too old", which points in completely the wrong direction. Had execution reached the signature, `explode(' ', $array)` would throw a `TypeError`. I checked both on PHP 8.4. The suggestion was to normalize values in both fallback branches and add a PSR-7 shaped test: ```php $v = is_array($v) ? reset($v) : $v; ``` > **Why this matters more than it looks** > A fix that turns a clear error into a misleading one makes the next debugging session longer than the one it saved. The headers are now found, so the developer stops looking at headers and starts looking at clocks. ##### Two SDKs, two fallback rules The PHP fallback requires all three headers from one brand: three `svix-*` or three `webhook-*`. The Ruby fallback picks each field independently (`svix-id || webhook-id`, and so on), so it can combine `svix-id` with `webhook-signature`. That is not a security problem, since every value still goes through the HMAC, but the SDKs now behave differently on mixed input. Grouping by brand in Ruby would keep them aligned. The `k.to_s` in the Ruby version also handles symbol keys, which is a nice detail. #### What happened next > **Status on 26 October 2026** > No replies and no commits after the review. All PHP and Ruby CI jobs pass, which is expected: none of the tests uses array values. #### Takeaways - Fixing the lookup is half of header handling. The value shape (string or list) is the other half. - PHP's loose conversions (`intval` on an array) turn type errors into wrong numbers. Validate types at the boundary. - When SDKs in several languages implement one protocol, keep their edge cases identical, including on malformed input. #### References - [svix-webhooks pull request #2732](https://github.com/svix/svix-webhooks/pull/2732) - [Issue #1013: support header names of all casing](https://github.com/svix/svix-webhooks/issues/1013) - [My review](https://github.com/svix/svix-webhooks/pull/2732#pullrequestreview-5643272603) - [Inline: PSR-7 header values are arrays](https://github.com/svix/svix-webhooks/pull/2732#discussion_r4370903389) - [Inline: PHP and Ruby fallbacks differ](https://github.com/svix/svix-webhooks/pull/2732#discussion_r4370903392) - [PSR-7 MessageInterface::getHeaders](https://www.php-fig.org/psr/psr-7/) ### Signing Mailchimp webhooks in n8n: key rotation and the workflows that never get a secret n8n adds X-Mailchimp-Signature verification to its Mailchimp trigger. Two non-blocking points: keep every v1 signature for rotation, and existing workflows silently stay unverified. URL: https://www.f4.rs/journal/n8n-mailchimp-webhook-signature-review Published: 2026-10-26 > **At a glance** > Project: n8n, the workflow automation platform. Pull request #41561 by an n8n team member. My role: outside reviewer, two suggestions marked as not blocking. Status on 26 October 2026: open, CI green, no reply to the review. #### The problem From the pull request: before this change, the Mailchimp Trigger node "accepted every POST to its webhook URL". Anyone who learned the URL could start workflow runs with made-up Mailchimp events. According to the pull request, Mailchimp now sends an `X-Mailchimp-Signature` header containing a timestamp and an HMAC-SHA256 of `.`, keyed with a signing secret that Mailchimp returns once, in the response that creates the webhook. #### The proposed fix The node stores the secret when it creates the webhook and verifies every delivery with n8n's shared helper, which compares in constant time and rejects timestamps older than five minutes: MailchimpTriggerHelpers.ts: ```ts const hmac = createHmac('sha256', secret); hmac.update(`${timestamp}.`); hmac.update(payload); return hmac.digest('hex'); ``` MailchimpTrigger.node.ts: ```ts if (!verifySignature.call(this)) { const res = this.getResponseObject(); res.status(401).send('Unauthorized').end(); return { noWebhookResponse: true }; } ``` It signs the raw body rather than the parsed form body, which matters because Mailchimp posts form-encoded data and re-serializing it would not reproduce the original bytes. The tests cover a wrong secret, a changed body, a missing header, an expired timestamp and a missing raw body. #### What I found in review ##### Keep every v1 signature The header parser keeps only the last `v1=` value. In the `t=...,v1=...` scheme that Stripe uses, and that this header format mirrors, the header can carry one signature per active secret while a secret is being rotated, and the receiver should accept if any of them matches. With "last one wins", a valid first signature fails with 401. Collecting all `v1` values and comparing each in constant time costs little; a test with `t=...,v1=,v1=` would pin it. > **What I could not confirm** > I did not find Mailchimp documentation saying it sends multiple signatures. I presented this as hardening, not as a bug. ##### The upgrade path is silent The secret is captured in `create`. For a workflow that already has a Mailchimp webhook, `checkExists` returns `true`, so `create` never runs, no secret is stored, and verification is skipped because there is no secret to verify with. Existing workflows therefore stay unverified until someone deactivates and reactivates them, and nothing tells their owners. I suggested one of: 1. log a one-time warning when a request arrives and no secret is stored, or 2. make `checkExists` return `false` when a webhook id exists but the secret is missing, after deleting the old webhook so no duplicate is created; existing workflows then pick up signing on their next activation. The documentation checkbox in the pull request was also unticked, and the "deactivate and activate to enable verification" step belongs there. ##### Say when it degrades If the create response carries no `signing_secret`, the node falls back to accepting unsigned requests. A `this.logger.warn(...)` there would make that visible. > **Context** > An automated reviewer had flagged a stale-secret issue earlier; the author fixed it in commit 56d294c. The author deferred two other automated findings (secrets in workflow static data, duplicated helper code) to follow-ups. My review was on the head that already included that fix. #### What happened next > **Status on 26 October 2026** > No reply and no new commits. All checks pass; patch coverage is 91.89%. #### Takeaways - Signature schemes with a list of signatures exist for key rotation. A parser that keeps one value breaks rotation. - When a provider shows a secret only once, every integration created before your code existed has no secret. Plan the upgrade path, or at least make the unprotected state visible. - "Fail open when there is no secret" is a compatibility decision. It should be a logged one. #### References - [n8n pull request #41561](https://github.com/n8n-io/n8n/pull/41561) - [My review](https://github.com/n8n-io/n8n/pull/41561#pullrequestreview-5643272516) - [Inline: multiple v1 signatures](https://github.com/n8n-io/n8n/pull/41561#discussion_r4370903313) - [Inline: warn when no signing secret](https://github.com/n8n-io/n8n/pull/41561#discussion_r4370903317) - [Commit 56d294c: clear stale secret](https://github.com/n8n-io/n8n/commit/56d294c00a7a25addb30e3c3db47551a7fb3a2eb) - [Stripe: verifying signatures and secret rolling](https://docs.stripe.com/webhooks#verify-manually) ### postMessage origin and source checks in the Hyperswitch web SDK, and why the fix should ship alone Hyperswitch Web validated neither origin nor source before confirming a payment from a message. The sender checks are solid; the pull request also bundles behavior changes and describes features that are not in the code. URL: https://www.f4.rs/journal/hyperswitch-web-postmessage-origin-validation-review Published: 2026-10-26 > **At a glance** > Project: hyperswitch-web, Juspay's browser SDK for the Hyperswitch payments orchestrator. Pull request #1824, fixing issue #1823, both by the same author. My role: outside reviewer. Status on 26 October 2026: open; the branch was force-pushed after the review without a reply, and two Playwright checks fail. #### The problem A merchant page loads the Hyperswitch loader, which mounts the payment form in an iframe and opens overlays for 3-D Secure and wallets. The parts talk through `window.postMessage`. Issue #1823 describes the gap: > The merchant-side `handleSdkConfirm` message listener does not validate `MessageEvent.origin` or `MessageEvent.source` before invoking payment confirmation. A cross-origin frame or window holding a reference to the merchant checkout can send a confirmation command that the loader forwards to its mounted payment iframe. Because the forwarded message then comes from the legitimate merchant page, it also passed the card component's parent-window check. The issue rates it medium severity. #### The proposed fix A new module pins both the sending window and a concrete origin: src/Utilities/PaymentMessageSecurity.res: ```rescript // A window identity alone survives navigation; always authenticate its origin too. let isConcreteOrigin = origin => origin !== "" && origin !== "*" && origin !== "null" let isExpectedSender = (~source, ~origin, ~eventSource, ~eventOrigin) => isConcreteOrigin(origin) && eventSource === source && eventOrigin === origin ``` The confirmation handler now runs only for messages from the payment iframe, and return URLs are compared in full (protocol, host, path, hash and query, except the parameters the SDK adds), with `javascript:`, `data:`, `vbscript:`, `blob:` and `file:` blocked. #### What I found in review ##### The security core is right Checking only `event.source` is not enough, because a frame can navigate to another origin while keeping the same window identity. Checking only `event.origin` is not enough when several frames share an origin. Pinning both, rejecting `"*"` and the opaque `"null"` origin, and comparing the full return URL rather than its origin close the forgery paths in the issue. I said so in the review. ##### The description promised things the code does not do The pull request description documents public behavior changes that merchants and the changelog would rely on: a `confirmation_in_progress` error for a second confirm, `payment_element_unmounted` on unmount, a `SDK_SESSION_TOKEN_DELIVERY_FAILED` event, and resolving before a canceled `beforeunload` navigation. After the last commit at review time, none of these strings were in the diff, and a test comment said the opposite of the first one: "As on main, a later confirmation takes over the response listener". I asked for the description to match what ships, or for the missing commits to be pushed. > **Correction** > In the review I named the test file as payment-message-boundaries.spec.ts. The comment is in payment-message-compatibility.spec.ts, at the reviewed commit and at the current head. ##### Split the security fix from the behavior changes At review time the pull request was about 4,500 lines across about 90 files. The origin and source validation is self-contained: the security module, the handler guards and the 3-D Secure relay allowlist. The wallet results, overlay lifetime and polling changes are behavior changes with their own compatibility risk. In one pull request, a regression in the second group would block or revert the security fix with it. > **Why split** > A security fix should be easy to review, easy to backport and impossible to lose in a revert. Bundling it with behavior changes makes all three harder. #### What happened next > **Status on 26 October 2026** > The author force-pushed a new head after the review without replying. The description was last edited before the review and still lists the four behaviors; none of the three strings appear in the current diff. The pull request was not split. The build and CodeQL pass; the Playwright live and hermetic suites fail, and I have not checked whether the cause is the code or the environment. #### Takeaways - For `postMessage`, authenticate both `event.source` and `event.origin`, and send with an explicit target origin, never `"*"`. - A pull request description is release-note material. If it describes behavior the code does not have, someone will build on it. - Ship security fixes on their own. #### References - [hyperswitch-web pull request #1824](https://github.com/juspay/hyperswitch-web/pull/1824) - [Issue #1823: loader accepts confirmation messages from untrusted windows](https://github.com/juspay/hyperswitch-web/issues/1823) - [My review](https://github.com/juspay/hyperswitch-web/pull/1824#pullrequestreview-5643634678) - [MDN: Window.postMessage security concerns](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#security_concerns) ### A refund race in Medusa: why counting pending refunds overstates refunded_amount Medusa recomputes the refunded amount after a provider rejects a refund, which fixes the race the test forces. The total still counts unconfirmed refunds, and the recompute in the catch block can hide the provider error. URL: https://www.f4.rs/journal/medusa-refund-race-refunded-amount-review Published: 2026-10-26 > **At a glance** > Project: Medusa, an open-source commerce platform, payment module. Pull request #17489 by a Medusa team member. My role: outside reviewer. Status on 26 October 2026: open, CI green, no reply to the review. #### The problem From the pull request: `refundPayment` saves the refund row before calling the payment provider. If another refund on the same payment collection succeeds in between, it recomputes the collection total while the doomed row is still there and counts it. The failing refund then soft-deletes its row but does not recompute, so the collection stays overstated, for example 80 instead of 50. Cancelling an order refunds payments in parallel, so this reaches real orders, and it made an order test flaky in CI. The row lock (`SELECT ... FOR UPDATE`) is taken while the refund row is created; the provider call runs after it. That is what allows the interleaving. #### The proposed fix Recompute after the soft-delete, then rethrow: src/services/payment-module.ts: ```ts await this.refundService_.softDelete({ id: refund.id }, sharedContext) // A concurrent refund may have totaled the collection while this row // was still live, so recompute now that it's excluded. await this.maybeUpdatePaymentCollection_(payment.payment_collection_id, sharedContext) throw error ``` The new test is a good reproduction: the provider mock for a 30 refund first runs a 50 refund, then throws "provider timed out", and the collection must end at 50. #### What I found in review ##### The total counts refunds nobody has confirmed The cause underneath is in the aggregate. `maybeUpdatePaymentCollection_` adds up every refund that is not soft-deleted: ```ts for (const refund of refunds) { refundedAmount = MathBN.add(refundedAmount, refund.amount) } ``` That includes `pending` refunds the provider has not confirmed. So `refunded_amount` is still overstated between the concurrent success and the new recompute, and it stays overstated if the process dies after the provider fails but before the soft-delete. Counting only `succeeded` refunds would close both gaps. > **The catch** > The migration that added the status column, Migration20260924125937, uses `not null default 'pending'` with no backfill. Every refund created before it reads as pending. Filtering on `succeeded` would drop all of them from the total, so they need a backfill to `succeeded` first, which would also correct the status those older refunds report today. ##### Do not let cleanup replace the real error The new recompute runs inside the `catch` block, before `throw error`. If it fails (a database error, a lock timeout), its exception replaces the provider's. The caller then sees a database failure instead of the refund rejection it makes decisions on. Catching and logging the recompute error, then rethrowing the original, keeps the failure semantics unchanged. #### What happened next > **Status on 26 October 2026** > No replies and no new commits. CI passes (33 checks). #### Takeaways - Write the row first, call the provider second, is a good pattern for idempotency and auditing. It also creates a window where the outcome is unknown, and every aggregate must decide how to treat that window. - Money totals should be computed from confirmed states. - A `NOT NULL DEFAULT` on a status column silently rewrites history. Backfill in the same migration. - Best-effort work in a `catch` block must not be able to change which error escapes. #### References - [Medusa pull request #17489](https://github.com/medusajs/medusa/pull/17489) - [My review](https://github.com/medusajs/medusa/pull/17489#pullrequestreview-5643466488) - [Inline: do not replace the provider error](https://github.com/medusajs/medusa/pull/17489#discussion_r4371041871) ### Orphaned 3DS2 postMessage listeners in Adyen Web, and a detached-node check that misses shadow DOM A Drop-in torn down without unmount leaves a window message listener behind, so a later 3DS2 challenge completes twice. The guard uses document.contains, which is always false inside a shadow root. URL: https://www.f4.rs/journal/adyen-web-3ds2-duplicate-challenge-completion-review Published: 2026-10-26 > **At a glance** > Project: Adyen Web, Adyen's browser checkout SDK (Drop-in and Components, built on Preact). Pull request #4273. My role: outside reviewer. Status on 26 October 2026: open, no reply to the review; one WebKit accessibility end-to-end job fails, cause not checked. #### The problem During a 3-D Secure 2 challenge, the SDK listens on `window` for a `postMessage` from the notification page inside the challenge iframe. The listener is removed when the challenge promise settles or when the component unmounts. From the pull request description: when Drop-in is torn down without `component.unmount()`, for example during single-page-app navigation, `componentWillUnmount` never runs and the listener stays on `window`. On the next payment, both the orphaned listener and the new one process the challenge result, which leads to duplicate `/paymentDetails` calls against one session. The shared message handler checks the origin, that the data is JSON and the message type. It does not check which transaction the message belongs to, so any live listener accepts any challenge result from the right origin. #### The proposed fix Skip the callbacks when the component's root node is no longer in the document: DoChallenge3DS2.tsx: ```ts private isDetachedFromDOM(): boolean { return !this.base || !document.contains(this.base); } ``` Both the success and the timeout path return early when it is true. #### What I found in review ##### document.contains does not see into shadow roots `Node.contains` walks the light DOM. A node inside a shadow root is not a descendant of `document` in that sense, so for a Drop-in mounted inside a custom element with a shadow root, `document.contains(this.base)` is always `false`. Those integrations would drop every challenge result and the timeout too, with no callback, leaving the shopper on the challenge screen. `isConnected` gives the same answer for the case in this pull request and stays `true` inside an attached shadow tree: ```ts private isDetachedFromDOM(): boolean { return !this.base?.isConnected; } ``` > **Not reproduced** > This is a deduction from the DOM specification, not a reproduction with a real shadow-root integration. I do not know how many merchants mount Drop-in that way. ##### Attached but stale The guard covers a node that has left the DOM, not a stale instance whose node is still attached, such as a hidden Drop-in or one kept in a cached view. Its listener would still resolve on the next payment's challenge result. I asked whether the notification message carries the `threeDSServerTransID`, so the handler could reject results for other transactions at the source. > **Context the review missed** > Adyen shipped exactly that check in pull request #2081 in 2023 and rolled it back in #2231, saying the validation was "too strict and causes issues with conversion since sometimes the threeDSServerTransID's might not match due to ACS or other reasons". I did not mention that history in the review. It makes the transaction-id route a known tradeoff rather than an easy fix, and it explains why the pull request uses a DOM check instead. ##### Same pattern, smaller window `DoFingerprint3DS2` registers the same kind of listener for the device fingerprint step, with a 10-second timeout. The window is small, but an orphaned listener can still resolve on a second payment's fingerprint result inside it. A unit test that detaches `base` and dispatches a `message` event would cover both components more cheaply than the end-to-end case. #### What happened next > **Status on 26 October 2026** > No reply and no commits after the review. The pull request's unit-test checkbox is unticked. #### Takeaways - Listeners on `window` outlive components that are removed outside the framework's lifecycle. Correlate the message with its transaction, or guarantee cleanup. - For "is this element still on the page", `isConnected` is the shadow-DOM-safe check; `document.contains` is not. - Read the history of a file before proposing the obvious fix. Sometimes it was already tried. #### References - [Adyen Web pull request #4273](https://github.com/Adyen/adyen-web/pull/4273) - [My review](https://github.com/Adyen/adyen-web/pull/4273#pullrequestreview-5643564235) - [Inline: document.contains and shadow roots](https://github.com/Adyen/adyen-web/pull/4273#discussion_r4371088900) - [Inline: stale but still attached instances](https://github.com/Adyen/adyen-web/pull/4273#discussion_r4371088905) - [Earlier PR #2081: transaction id check](https://github.com/Adyen/adyen-web/pull/2081) - [PR #2231: that check rolled back](https://github.com/Adyen/adyen-web/pull/2231) - [MDN: Node.isConnected](https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected) ## Pages ### Accessibility URL: https://www.f4.rs/accessibility F4 aims to meet WCAG 2.2 level AA. This page states what has been tested and what is known not to work. #### What the site does - Every page has a skip link, landmarks and a logical heading order. - All controls work with a keyboard, and focus is always visible. - The mobile navigation drawer traps focus while open, closes with Escape and returns focus to the button that opened it. - Animations respect the reduced motion setting. The signal trace on the homepage and project pages has a text equivalent and is not needed to use the site. - Colors are chosen for at least 4.5:1 contrast for text in both light and dark themes. - Status is never shown by color alone. #### How it was tested Automated checks with axe-core run in the end-to-end test suite on the main pages in both themes and at mobile and desktop widths. Keyboard navigation was tested by hand. Screen reader testing with NVDA and VoiceOver has not been completed yet; this page will say so until it has. #### Reporting a problem If something does not work for you, open an issue on GitHub describing the page and what happened. I treat accessibility problems as bugs. ### Privacy URL: https://www.f4.rs/privacy What this site collects, stores and sends. This notice describes the code as it is deployed. #### Visitors - F4 does not use analytics, advertising or tracking scripts, and sets no cookies for visitors. - Your theme choice (light, dark or system) is saved in your browser's local storage so the page does not flash the wrong theme. It never leaves your browser. - Search runs in your browser against a static index of published content. Queries are not sent to a server. - The hosting provider, Vercel, processes requests to deliver the site and may keep standard request logs. See Vercel's privacy policy for details. - Fonts are served from this site, not from a third-party font service. #### GitHub data The [activity](https://www.f4.rs/activity) and [open-source](https://www.f4.rs/open-source) pages show public data from the GitHub API about my own account: public repositories, public events, pull requests and reviews. No private repository data is read or shown. #### Administration The content management area is restricted to one administrator. Signing in uses GitHub or a one-time email link and sets a session cookie only for that administrator. #### Contact Questions about this notice: open an issue on GitHub or use the contact details on the [contact page](https://www.f4.rs/contact). ### Now URL: https://www.f4.rs/now What I am working on in public, updated by hand. It only covers work that can be seen on GitHub. #### PenSec New on 27 October 2026: [PenSec](https://www.f4.rs/work/pensec), an open-source application security assessment engine for your own projects, released under AGPL-3.0. It runs as a Go CLI and as a Claude Code plugin, is passive by default, and reports what was not checked next to what was. Next up are prebuilt binaries so the plugin works when installed from GitHub ([#4](https://github.com/ratomirjovanovic/pensec/issues/4)) and a rewritten README ([#3](https://github.com/ratomirjovanovic/pensec/issues/3)). #### PandaPayLens Version 0.14.0 shipped on 26 October 2026 and is now on npm as `pandapaylens`. The open roadmap items are keyboard navigation in the timeline ([#3](https://github.com/ratomirjovanovic/PandaPayLens/issues/3)) and an `--output` option for the CLI ([#4](https://github.com/ratomirjovanovic/PandaPayLens/issues/4)). Six good first issues are open for contributors. #### Reviews in other projects I review payment, webhook and security changes in open-source projects: twelve so far, in Stripe, PayPal, Razorpay, Adyen, Payload, Medusa, Better Auth, Svix, Standard Webhooks, n8n and Hyperswitch code. Each one has a [write-up in the journal](https://www.f4.rs/journal/twelve-payment-and-webhook-code-reviews) with the problem, the review and the outcome; the [activity page](https://www.f4.rs/activity) links the original threads. #### F4 This site. It is built with Next.js and Payload CMS, and its own source of truth for activity is the public GitHub API. ### About URL: https://www.f4.rs/about I am Ratomir Jovanovic. I have spent 18 years building products across SaaS, fintech and payments, much of it in regulated, audited and security-sensitive environments. #### What I work on now I build payment infrastructure and security governance tools. On GitHub I maintain [PandaPayLens](https://www.f4.rs/work/pandapaylens), an open-source, local-first toolkit for debugging payment integrations, and [PenSec](https://www.f4.rs/work/pensec), a local, evidence-based application security assessment engine and Claude Code plugin. I also review payment and webhook code in other projects. The [open-source page](https://www.f4.rs/open-source) lists the public repositories and contributions that F4 can verify from GitHub. My current ventures, as listed on my GitHub profile, are Payshen (payment orchestration, where I am founder and managing director), afend (ISO/IEC 27001:2022 readiness), Nanotesting (automated security assessment) and SodaSoft (a venture studio). Payshen's client libraries for PHP, Python and WooCommerce are public under the meetsodasoft organization. #### Background Before this I was Head of Product at Webio (2023 to 2025), working on product and QA governance for a platform serving enterprise clients in regulated collections and payments, inside an ISO/IEC 27001-certified environment. Earlier roles were UX Enterprise Architect at Japan Tobacco International, Chief User Experience Architect at Crossover for Work, and Chief Product Officer at Skout Deals. I am an ISO/IEC 27001:2022 Lead Auditor (CQI/IRCA). #### Why F4 exists Most of what makes engineering work good or bad is invisible in a finished product: the constraint that ruled out the obvious design, the bug that only appeared on a CI runner, the test that proved a fix. F4 keeps that record. Every project page and problem record links to the commits, issues and pull requests behind it, and anything without evidence is left out. #### Elsewhere GitHub is the primary record of my work: [github.com/ratomirjovanovic](https://github.com/ratomirjovanovic). My personal site with essays on GRC, security, product and payments is [ratomir.com](https://ratomir.com). ## Open source - [ratomirjovanovic/PandaPayLens](https://github.com/ratomirjovanovic/PandaPayLens): Source-available payment error decoder and transaction flow visualizer for Stripe, Adyen, payabl. and more. - [ratomirjovanovic/pensec](https://github.com/ratomirjovanovic/pensec): Local, evidence-based application security assessment engine and Claude Code plugin - [ratomirjovanovic/ratomirjovanovic](https://github.com/ratomirjovanovic/ratomirjovanovic): Profile README