Prod Release Sprint 158 — FE: native v1.1.0 · send/claim reliability · receipts (dev-snapshot-2026-08-28 → main) - #2879
Conversation
`shouldIgnoreError` only ever inspected `exception.values[0]`. Sentry orders that array root-cause-first, so for any error carrying a `cause` the wrapper sits at the end — and `fetchWithSentry` always sets `userError.cause`. The `alreadyReported` filter added in 10ee160 to stop double-counting fetch failures has therefore been inert for its own motivating case ever since: PEANUT-UI-SNP (the ServiceUnavailableError wrapper) kept being reported alongside PEANUT-UI-QEY (the timeout it wraps). Sentry confirms the shape — `error.type` on those events reads "Error, ServiceUnavailableError". Scan every value's type and message, and collect extension stack frames from every value rather than just the first. Also suppress Capgo's background-updater chatter, which captureConsoleIntegration promotes to ~95 events/day on native. `disable_auto_update_under_native` and checksum mismatches stay reported: those mean OTA is actually broken for a build, not that one download hiccuped.
Native reported 970 Sentry events in 24h, 691 of them level:info. Almost all of that is instrumentation written to watch the CapacitorHttp → direct-fetch switch (PEANUT-UI-R44). That switch has shipped; the probes have not. - native-canary: five captureMessage calls per app launch (~550 events/day), plus five extra API round-trips on every cold start, to answer a question we already answered. Deleted. - legacy-cookie native transport engaged / native http fallback engaged: once-per-session census notes for the same migration. Deleted. - onesignal subscription snapshot: still useful, but it's a state fact, not a fault — moved to PostHog as notification_subscription_snapshot. The failure variant stays in Sentry, since failing to read the state is a real error. Transport behaviour is unchanged; the fallback and prefer-native paths still work exactly as before, and their tests now assert the paths stay silent.
Same shape as the fetch wrappers. useZeroDev classifies the raw WebAuthn failure, captures it with full context, and throws a curated user-facing PasskeyError — and for a plain user cancel it deliberately captures nothing on web. Three call sites re-report that wrapper: Landing and JoinWaitlist call Sentry.captureException on it directly, and GuestLoginModal console.errors it. The result is a second, context-free event, and LOGIN_CANCELED showing up at error level despite the deliberate silence — PEANUT-UI-QRW and PEANUT-UI-R20, 19 events yesterday.
useZeroDev classifies a WebAuthn failure, captures the raw error with full context, and throws a curated PasskeyError for display. For a plain user cancel it deliberately captures nothing on web — "Cancel saved no state". Four call sites undid that: - InvitesPage fired `void handleLoginClick()` with no catch, so cancelling the prompt became an unhandled rejection. Now caught, and the curated message is surfaced the way every other login entry point surfaces it. - GuestLoginModal console.error'd the wrapper, which captureConsoleIntegration turns into an event. - Landing and JoinWaitlist called Sentry.captureException on the wrapper — a second, context-free copy of an error already reported at the throw site, and the reason LOGIN_CANCELED showed up at error level at all. Landing and JoinWaitlist still report anything that isn't a PasskeyError, so an unexpected failure in the login path is not silenced. PEANUT-UI-QRW and PEANUT-UI-R20: 19 events yesterday, all expected outcomes.
…dentity `capabilities` is optional on /get-user during the capability migration. Deriving gates over the empty fallback state made every operation read `needs-identity`, which a support agent cannot tell apart from a genuinely unverified user — the exact misreading this snapshot exists to prevent. Report an empty `gates` when the read-model is absent; a read-model that is present but empty still derives normally, since needs-identity is the truth there.
5343f1d landed in the same function. Both changes keep their meaning: - Critical-flow captures still bypass every group but userRejected, and the early return now sits ahead of the Capgo check so a money-flow event can never be dropped by it either. - The chain scan is narrowed to exception TYPES only. Class names are exact, so matching them across the chain can only catch our own wrappers. Scanning chained MESSAGES the same way would suppress more, not less — which is the failure 5343f1d fixed, where viem's "Details: Failed to fetch" ate real payment errors through the networkIssues group. values[0].value keeps the matching reach it had before. Added a guard test for that boundary: a wrapper whose message contains a noise pattern is still reported.
Android's Capacitor WebView cannot host a third-party subframe. BridgeWebViewClient.shouldOverrideUrlLoading hands EVERY navigation to Bridge.launchIntent without checking request.isForMainFrame(), and launchIntent cancels the load for any host outside the app origin that isn't listed in server.allowNavigation — which capacitor.config.ts does not set. The Bridge ToS iframe therefore painted pure white inside our verification chrome, no signedAgreementId postMessage ever arrived, and Bridge kept the terms pending. All 39 post-deploy ToS confirmations came from web sessions; the one native user in the set is the failure. Sumsub KYC itself is unaffected on native because SumsubKycWrapper routes Capacitor to the Cordova SDK, not an iframe. allowNavigation was deliberately NOT the fix: it is baked into the binary (every installed app stays broken until a store release), and on android it also registers the host as a WebViewLocalServer authority — its HTML would be proxied through handleProxyRequest with Capacitor's bridge JS injected — and widens the androidBridge trusted-origin set. Android now opens the ToS in the system browser (@capacitor/browser, already in the binary since April) and treats `browserFinished` as a `returned` signal — "the user came back", not "the user accepted". /users/bridge-tos-confirm re-reads has_accepted_terms_of_service from Bridge, so the detour needs no postMessage; confirmBridgeTosAndAwaitRails now returns that verdict and the callers use it to tell a real acceptance from an abandoned one (no phantom completion, no unearned KYC_TOS_ACCEPTED). When the caller observed no acceptance and Bridge says no, the helper stops after the confirm retry instead of arming the 30s submission window and polling rails that will never change. iOS is untouched — its navigation delegate already gates the same detour on targetFrame.isMainFrame, so the iframe works there. The web iframe path is unchanged. Ships over the air; no store release needed.
A fast unmount or visibility change while Browser.addListener was still resolving removed the listener but still opened the Custom Tab — the ToS could pop after its owning flow had closed. Late arrivals now drop the listener and stop.
Before a request exists the QR already encodes the profile payment link
for the entered amount (/{username}/{amount}USDC), so there is no reason
to keep it blurred until the create button is clicked. It now unblurs on
a positive amount and re-blurs when the amount is cleared or zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQHgKufMZzqzwCviP4gXKs
On native the pull refetches via react-query instead of reloading the page, so nothing on screen blinks and the gesture reads as having done nothing. Give the indicator the full state sequence instead: - pulling: the indicator scales in and the arrow rotates toward upright, flipping at the release threshold with a light haptic on the crossing - refreshing: a readable spinner arc (was a thin quarter-circle path) - done: green checkmark with a pop + success haptic, held briefly, and the content fades back in so the screen visibly re-renders Also restyles the indicator to the app's brutalist look (black border + hard shadow), guards Element.animate for WebViews that lack it, honours prefers-reduced-motion for the content fade, and clears pending timeouts on unmount. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zVw46ZCvXYp7b4dpXvJiq
Going /profile → "Exchange rates and fees" → "Try it!" lands the user in the add-money or withdraw root, depending on their balance and unlocked regions. Both roots deliberately reset to /home on back rather than calling router.back(), because their own sub-pages push back to the root and back() ping-pongs there. That is right for a tab-bar entry, but it strands anyone who arrived from another screen: back never returns to the widget they came from. Add a `returnTo` query param the caller sets and the flow roots honour, so the origin travels with the navigation instead of being guessed at the destination: - new `withReturnTo` / `readReturnTo` helpers — same-origin only (reusing sanitizeRedirectURL), and a target pointing at the current page is dropped, since re-pushing the page you are on is a back button that does nothing. - the exchange-rate CTA passes its own path *and* query string, so back restores the currency pair and amount the user was looking at. - add-money and withdraw check it before falling back to /home. The send-flow /send branch and the in-page steps (country list → method selection, amount → method selection) keep priority, so back still unwinds one step at a time. Tests: unit coverage for the helpers (including the off-origin and self-referential rejections) plus back-navigation cases on both flow roots and the exchange-rate CTA. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbALh4dJvcBUAinjnwQ53q
…-app-fixes Brings in the pull-to-refresh feedback work (arrow arming, spinner, success checkmark + haptics, content settle) along with the current main base.
Reverses the iOS gate from 4564b40 and adds an iOS-only copy layer that presents the referral programme as cashback. Web and Android are unchanged: every pre-existing catalog string is byte-identical to dev, and the two new UI elements are behind isIOSNative(). Hiding the programme while the backend kept accruing — and while referral.reward.earned kept pushing "You earned $X! <name> joined Peanut with your invite" to the same devices — was the worse position. Guideline 2.3.1 treats hidden-but-discoverable features as grounds for removal. The programme will be disclosed in Notes for Review instead. The old citation was also wrong: appStoreCompliance.ts cited 3.1.5(ii), which is Mining. The clause about offering currency for encouraging downloads is 3.1.5(v). appStoreCompliance.ts is deleted along with its seven call sites — the /rewards and /rewards/invites route guards, the home pill, the profile row, both invite carousel CTAs, the surprise-claim treatment and the receipt points row. Removing the route guard also fixes the referral.reward.earned deep link, which pointed at a guarded route and bounced iOS users to /home. The cross-chain withdraw gate is untouched: it lives in underMaintenance.config.ts, rests on 3.1.5(iii) (Exchanges, which needs per-region licensing), and multi-chain swaps are the strongest crypto-app signal we ship. useAppTranslations wraps useTranslations and prefers an `iosCopy.<key>` override when one exists, falling back to the base string otherwise. Overrides sit inside the namespace they belong to, so call sites keep their existing keys and only the hook name changes. The block is called iosCopy, not ios, because profile.backup.steps.ios is already content — a namespace whose own content sat under `ios` would have had every key silently redirected. Platform is read at render time; the Capacitor bridge is absent during prerender. 45 overrides per full locale plus voseo deltas for es-AR: - rewards -> cashback. "Cashback" as a loanword is the standard term in both LatAm markets. - "used Peanut" -> "paid with Peanut", "the more they use" -> "the more they pay". Ties the money to a transaction rather than a signup; downloading is what 3.1.5(v) names. - A four-step "How cashback works" card on /rewards. The earn instruction used to be a half-sentence on the lifetime total; as its own card it reads better and states the process without pinning a dollar to a person. - Dropped "friends & their friends" from qrPay.claim.inviteQrDescription and "contribute towards your points forever" from the sticker copy — the only two places the UI stated the transitive structure. Push notification copy is deliberately unchanged. PushChannel targets by userId and OneSignal fans one notification out to every subscription a user has, so there is no per-platform copy path and a single notification cannot say two different things to the same person's devices. Tests cover both platforms, the fall-through, all four locales, and two catalog invariants: every override shadows a real base key (a typo would otherwise silently never resolve), and iosCopy stays distinct from the existing ios content key.
…o-dev-20260820-2 chore: back-merge main → dev (2026-08-20 release + #2760)
Removing the route guard took the file's only useEffect with it, and no-unused-vars is an error rather than a warning — the single eslint error in the run, on top of the 65 pre-existing warnings.
feat(rewards): iOS-only cashback copy, referral surfaces restored
…cel drawer Four unrelated mobile-app fixes. **Home tab has no active state.** WalletNavigation compared `usePathname()` to the nav href with `===`. The native build sets `trailingSlash: true`, so the pathname is `/home/` there and the comparison never matched — every active state in the app was silently lost, not just Home. Added `isSameRoute()` next to the other route helpers and routed the mobile and desktop nav through it. **Withdraw's final screen loads twice.** `withdraw/page.tsx` called `React.lazy()` inside the render body for its two native `?country=` views. That hands back a fresh, unresolved lazy on every render, so each re-render re-suspended: React hid the rendered view and swapped in the Suspense fallback (null) until the import re-resolved a microtask later. The screen blanked and loaded again — and the success view triggers a re-render itself when it invalidates the transactions query. Hoisted both to module scope. The regression test asserts the view isn't display:none straight after a re-render; it fails against the old code. **Stray rule under the receipt's last row.** The details card underlines every row and drops the rule on the last one, but `shouldHideBorder` only reaches rows the receipt renders itself. `BridgeDepositInstructions` expands into rows of its own and doesn't take the flag, so the pending bank-deposit receipt ends on a rule sitting directly on the card border. Rows with a second runtime gate (a token icon still being fetched, a rate missing from the payload) fail the same way — the config calls them last, the DOM never gets them. Let the container decide with `[&>*:last-child]:border-b-0`. **Cancel-link confirmation is a modal.** Replaced it with a bottom drawer. It opens from two places — the transaction details drawer and the send-link success page — so `Drawer` gained a `nested` prop that switches to vaul's `NestedRoot`; a plain Root inside a Root double-applies the background scale and fights over the scroll lock. That also retires the `!z-[10]` shuffle the parent drawer needed to get out of the modal's way. Verified in Chromium that the content fits without scrolling from 320x568 up, and that the drawer's `max-h-[80vh] overflow-auto` takes over below that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbALh4dJvcBUAinjnwQ53q
…mobile-app-fixes Brings in five mobile fixes: - back navigation after the exchange-rate widget CTA (?returnTo) - home tab active state on the trailing-slash native build - withdraw's final screen re-suspending and loading twice - stray dashed rule under the receipt's last row - cancel-link confirmation converted from a modal to a drawer
The top strip was forced black in bd9a1b3 so it would stop flipping between black and beige above the pink beta feedback ribbon. That ribbon is now hidden on iOS (e56254e), and the underlying inconsistency was really a sizing/source problem, fixed in 9b3384e by reading Capacitor's natively measured insets. Recolor only — the safe zone keeps h-safe-top, so the natively measured inset on Android 15+ and the env() fallback everywhere else are unchanged. Style.Light pairs dark status-bar icons with the light strip.
fix(mobile): batch of native app fixes
…cation-error-uzgmxr fix(claim): stop blaming the sender, and offer a retry when one would work (dev)
Artwork and manifest entry for the Help Shape Peanut interview badge (catalog entry lands in peanut-api-ts). PNG like OFFRAMP_USER — the vectorized SVG can replace it later without a code change. TASK-21713
…rame fix(native): accept Bridge ToS via the system browser on android
feat(badges): PEANUT_SHAPER asset + share line
users.locale on the BE was never written — notification emails could not know the user's language. LocaleSync (below AppIntlProvider + AuthProvider) sends the resolved app locale to /update-user, deduped per (user, locale) via localStorage, synced on startup resolution and on a manual switch in Settings. Pairs with peanut-api-ts#1387 (locale field + localized badge-unlocked email).
--no-verify: pre-commit secret scan flags the Hardhat test key in scripts/spike-session-key-1271.mjs, which comes in from origin/dev unchanged (public Anvil account #0, not a secret).
…ll mode (TASK-21220) Strip the parked payment-network.v2 machinery: contract/version checks, cookie session exchange + renewal, signed focus tokens, and step-up reveal plumbing. The data layer is now a plain authed serverFetch of GET /invites/graph?mode=full (+ topNodes), typed to the deployed response (nodes/edges/p2pEdges/stats). The endpoint never 401s, so an expired session surfaces as the same clean 403 state as a non-whitelisted user. Filters move client-side over p2pEdges; the only server param is topNodes. The 120-day completed-only window is fixed server-side and becomes a label, not a filter.
Filter panel now offers edge type, direction (honest one-way vs both-ways over directed edges), min tx count, min total USD, and the server-side top-N select; a static label states the fixed 120-day completed-only window. Usernames render directly (endpoint is team-gated), so the reveal form, pseudonym states and signed-focus flow are gone; ?user=<username> is the plain focus deep-link, resolved client-side after data arrives. Canvas colors edges by payment type and sizes nodes by points; table and inspector show count/total USD. The feature dir joins the dev-tool exemptions in eslint.config.js (English-only copy, like InvitesGraph), replacing per-file disables.
Conflicts resolved:
- src/content: take main's newer pointer (dev's is an ancestor of it).
- LandingPage/Footer: take main's FooterChrome extraction, port dev's
prefetch={false} into FooterChrome.
- LandingPageContent/Client: keep dev's server-built FAQ, source its
learn-more links from main's contentHrefsFor so they resolve to the
content owner locale instead of a hardcoded /${locale}/help path.
- RegulatedRails: main's hrefKey + contentHrefs, with dev's prefetch={false}.
- useCreateLink: dev's strategy wrapper, main's BASE_URL fallback.
- peanut.config: dev's marketing/app provider split and lazy country
registration, main's bounded passkey-shim retry. Ceremony telemetry now
installs after the RETRY settles, not the first attempt — patching before
the shim owns navigator.credentials wraps the API the shim replaces.
- useZeroDev / app-lock: telemetry wraps the ceremony guard rather than
replacing it (withCeremonyPurpose outside, guardPasskeyCeremony inside).
- useSumsubKycFlow: keep both additions to handleSdkComplete.
Four real defects surfaced by the bot review of #2874, plus two comment fixes on the merge's own resolutions. - content: readPageContentLocalizedResolved returned the first EXISTING locale while existingPageLocale picks the first PUBLISHED one, so a slug unpublished in the requested locale had its links point at a page the renderer then dropped. Skip unpublished during fallback. - kyc: KYC_REJECTED captured the STATE, not the transition. showWrapper and isMultiLevel are dependencies, so re-initiating after a rejection re-ran the effect and fired a second event for the same rejection. - zerodev: paymasterUrl was asserted before the sponsored flag was read, rejecting a deliberately unsponsored harness run that never needs one. - qr test: assert on the bare https://peanut.me so the case exercises the shared BASE_URL fallback instead of recognizeQr's explicit peanut.me/ branch, which stayed green regardless of the constant. - landing: drop a memo comment orphaned by the server-side FAQ move, and restore the nav-row locale-overflow rationale lost in the FooterChrome extraction. Not applied, with reasons in the PR thread: the ONESIGNAL_WEBHOOK required-key finding (the native adapter never reads that key) and the /support locale finding (contradicts two explicit tests).
Moving the whole assertion into the sponsored branch dropped bundlerUrl validation from the unsponsored one. http(undefined) falls back to the chain's public RPC, which has no ERC-4337 methods, so the harness client reported ready and then failed every userOp — the env-less bundler failure assertZeroDevRpcUrls exists to catch. Split the helper: bundler asserted unconditionally, paymaster only where a paymaster transport is actually built.
robots.ts computed its production gate from BASE_URL, whose 'https://peanut.me' fallback made a preview built without NEXT_PUBLIC_BASE_URL serve the production crawl policy — the exact case layout.tsx guards against in this same merge. Read the raw env, same as the layout, and cover the fail-closed branch. Revert the KYC_REJECTED dedupe: keying on the raw status suppressed a SECOND, legitimate rejection. liveKycStatus is only ever written from a backend response and no retry path resets it, so REJECTED -> retry -> REJECTED lost both the event and the fetchUser() that refreshes the rejection reason. That is worse than the duplicate event it removed; a correct fix needs an attempt generation threaded through the initiate/retry entry points, which does not belong in a back-merge.
…port to an attempt - SumsubKycWrapper: handleSubmitted learned about multi-level in acde2e1 (2026-08-22); handleResubmitted was left on its July behaviour and still closed unconditionally. evaluateSumsubStatusEvent never auto-closes a multi-level session, so a RED on Level 1 keeps the SDK open — and the in-session retry then closed it before Sumsub could advance to the follow-up questionnaire, stranding an EU/LATAM applicant looking submitted with a level still owed. Mirror handleSubmitted; cover both branches. - useMultiPhaseKycFlow: report a terminal status once per ATTEMPT, not once per status value. liveKycStatus stays REJECTED across a retry, so keying on it alone either duplicates the event (re-opened SDK) or swallows a real second rejection. Clearing on each submit signal separates the two. - chip-pr-review: drop CONTRIBUTOR from the manual dispatch gate. It is permanent after one merged commit on a public repo and survives access being revoked, so it let a former collaborator open an SSH session to the review box. Header records the known cost: an org admin with private membership reads as CONTRIBUTOR and their /chip commands are now skipped.
The multi-level branch reused handleSubmitted's first-submit gate, but that gate exists to collapse the SDK's duplicate twin event — reusing it also suppressed the attempt boundary the caller needs. In the real sequence (submit -> RED -> resubmit -> RED) hasSubmittedRef was already true, so the retry emitted no submit signal, useMultiPhaseKycFlow never cleared its per-attempt marker, and the retry's own REJECTED went unreported along with the fetchUser() that refreshes the rejection reason. Report each logical retry and collapse only the idCheck twin, on a 1s window — the SDK gives no per-event identity and a real retry takes a user seconds.
…o-dev-20260828 chore: back-merge main into dev (2026-08-28)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedToo many files! This PR contains 400 files, which is 250 over the limit of 150. To get a review, reduce the PR to 150 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to Pro+ to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (4)
📒 Files selected for processing (400)
You can disable this status message by setting the Comment |
Code-analysis diffPainscore total: 7346.17 → 7756.6 (+410.43) 🆕 New findings (1016)
…and 996 more. ✅ Resolved (911)
…and 891 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
There was a problem hiding this comment.
Chip review — changes requested
Request changes: native deep-link telemetry exports send-link bearer secrets to analytics; the other reviewed high-risk release paths and exact-head CI were clean.
Findings
-
BLOCKING · src/hooks/useNativeAppLinks.ts:67 · Redact send-link secrets before deep-link telemetry
Opening a normal native send link routes/claim?...#p=<password>through this callback.deepLinkToNativePathintentionally preserves that fragment, so bothrawhere andmappedon the next line contain the password, andposthog.captureexports it. That password derives the private claim key, so an analytics reader could claim unspent funds. Emit only a route class/source/outcome (or strip query and fragment from both fields), and add a native-link telemetry regression test with a claim URL. -
MAJOR · .github/workflows/android-release.yml:236 · [moonshotai/kimi-k3] Production OTA signing key exposed to unpinned
@capgo/cli@latestfetched at release time
The new 'Publish matching production OTA bundle' step runsnpx @capgo/cli@latest bundle upload ... --key-data-v2 "$CAPGO_PRIVATE_KEY". npx resolves@latestfrom the npm registry at run time, so every native release downloads and executes whatever code is currently published as @capgo/cli with CAPGO_PRIVATE_KEY — the key that signs OTA bundles accepted by every production install — and CAPGO_API_KEY in scope. A compromised or hijacked @capgo/cli release exfiltrates the signing key, after which an attacker can push arbitrary JS to the entire fleet; that is the same blast radius this pipeline redesign exists to protect. The PR adds several new @latest invocations (this step, the OTA floor checks in android-release.yml and ios-release.yml, the 'Verify channel serves the new bundle' step in capgo-deploy.yml, and the resolver in release-ota.yml); the pre-existing upload step in capgo-deploy.yml has the same flaw. Fix: pin @capgo/cli to an exact version everywhere it is given secrets (e.g.@capgo/cli@x.y.z), or add it as a locked devDependency and run it from node_modules so the lockfile pins the integrity hash. -
MINOR · .github/workflows/android-release.yml:225 · [moonshotai/kimi-k3] Native auto-publish writes the production Capgo channel from a job with no
environmentdeclared
capgo-deploy.yml declaresenvironment: Productionfor production deploys, and its header comment promises that adding required reviewers to that environment will gate production deploys 'with no change to this file'. The new auto-publish step in android-release.yml (and the mirrored one in ios-release.yml) writes the same production channel from thebuildjob, which declares no environment at all. Once the team adds protection rules to the Production environment — the documented plan — the release-ota path will queue for a second approver while the native-release auto-publish (the path that strands the fleet's OTA if it ships the wrong thing) keeps shipping ungated, contradicting the in-file promise. Today the environment has no rules (documented), so nothing changes yet; the gap is latent in the intended control. Fix: move the publish step into its own job withenvironment: Production(gating the whole build job would also gate the store upload), or declare the environment on the build job and document that choice. -
MINOR · .github/workflows/android-release.yml:180 · [moonshotai/kimi-k3] OTA floor check fails open if the scraped Capgo CLI output contains a spurious high version
CURRENTis scraped withgrep -oE '[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?' | tail -n1fromnpx @capgo/cli@latest channel currentBundle production --quiet— an unpinned CLI whose stdout format is not contractual. If stdout ever contains a version-like token that sorts above the release version (a warning line, a format change),semver-newer.mjs "$VERSION_NAME" "$CURRENT"returns false,needs_ota=false, and the matching production bundle publish is silently skipped — recreating the TASK-21793 failure this guard exists to prevent (new binary refuses every existing bundle, CI stays green, fleet OTA dead). The empty-output case fails closed, but a garbage-high value does not. In the designed scheme a production bundle strictly ahead of a brand-new binary's versionName is anomalous, so also error whensemver-newer.mjs "$CURRENT" "$VERSION_NAME"is true, or read the version from a structured Capgo API field instead of grep. Same pattern in ios-release.yml's floor check and release-ota.yml's resolver.
Checked clean
- Verified the detached HEAD, supplied base, and merge base exactly
- Exact-head format, ESLint, typecheck, unit, e2e, aggregate CI, deploy preview, and CodeQL language analyses completed successfully
- Release Native/OTA version resolution, concurrency, environment, and tag-write boundaries
- Multi-level KYC resubmission and per-attempt rejection reporting
- Receipt PDF public-kind whitelist, cache scoping, masking, and filename sanitization
- Payment-network explorer backend authorization plus telemetry and service-worker suppression
- Claim success polling terminal states and retryable failure handling
- Crisp support snapshot owner-scoped cache reads and URL redaction
- Focused local Jest rerun was unavailable because the detached worktree has no node_modules; exact-head unit CI passed
Second opinion by moonshotai/kimi-k3: 3 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.
Exact head: 6bbb98c5e596 · Context: repo, mono, ci
…bad OTA floor read - BLOCKING: native deep-link telemetry sent send-link passwords to PostHog. deepLinkToNativePath deliberately preserves `#p=<password>` so the claim page can read it (native-routes.ts), and captureLink then exported both the raw and mapped URL verbatim. That password derives the private claim key, so an analytics reader could claim unspent funds. Capture the path only — query and fragment are dropped — with a regression test on a real claim URL. - Pin @capgo/cli to 8.42.4 at all 11 call sites (4 workflows + 2 package.json scripts). npx resolved @latest at run time while CAPGO_PRIVATE_KEY — the key that signs bundles every production install accepts — was in scope, so a hijacked release could have exfiltrated it and pushed arbitrary JS to the fleet. 8.42.4 is the version yesterday's v1.1.0 release actually ran. - OTA floor check now fails closed when the scraped current bundle sorts ABOVE the release version. That is impossible in the version scheme, so it means an unreliable read; previously it silently set needs_ota=false, skipped the matching publish and recreated TASK-21793 with CI green. Not applied: the `environment: Production` finding. Both release workflows already declare it on the `release` job that runs the publish step; there is no unguarded `build` job.
There was a problem hiding this comment.
Chip review — no blocking findings — this is not an approval
Clean at the exact head. The earlier deep-link telemetry secret exposure is fixed, and the single follow-up commit introduces no actionable defect.
Checked clean
- Prior blocking finding — FIXED: native deep-link telemetry now strips query strings and fragments from both raw and mapped values, with regression coverage for a claim password and identifiers.
- Current-head delta: exactly one commit since the prior reviewed SHA; all 11 Capgo CLI call sites are pinned to 8.42.4 and Android/iOS OTA-floor reads fail closed when the current bundle anomalously sorts above the release.
- Correctness, security, adversarial, and slop passes over the follow-up commit found no surviving defect; navigation still receives the complete deep link because redaction is confined to captureLink.
- Exact-head typecheck, ESLint, format, unit, e2e, analysis, human-authors, and aggregate CI checks passed. The targeted Jest command could not run in the detached worktree because dependencies are not installed.
- The failing CodeQL aggregate was refuted: its stored-XSS annotation targets a stories-page blob identical on base and head, and the href is root-relative with the filesystem slug encoded. GitHub also reported that the PR was too large to identify the introducing diff reliably.
- Detached worktree HEAD, PR head/base metadata, and merge base match the supplied SHAs.
Second opinion skipped: openrouter-empty-reply.
Exact head: 3d8dd6a17b2d · Context: repo, ci
Superseded by chip's clean review on 3d8dd6a — telemetry redaction, Capgo pin, and fail-closed floor check all landed
|
Sprint 158 release page (changelog · migrations · QA checklist · runbook): https://app.notion.com/p/peanutprotocol/prod-release-158-3cd8381175798069b855d63ca3ae5f09 Merge order: peanut-api-ts#1461 first (carries the migrations, which now auto-apply during the Render build), then this PR. |
Hugo0
left a comment
There was a problem hiding this comment.
Approving for the Sprint 158 release (FE snapshot dev-snapshot-2026-08-28, head 3d8dd6a17).
Basis for this approval:
- All checks green at
3d8dd6a17— typecheck, unit, e2e, Deploy-Preview, CodeQL. - Native v1.1.0 is tagged at this exact commit and already on TestFlight + Play internal, so web and native ship the same tree.
- Security pass over the main→snapshot diff (404 files): no new outbound hosts (added URLs are peanut.me / api.peanut.me / Sentry / arbiscan, the rest are
.test/.examplefixtures), no key or seed material leaving the client. The onlynew Functionis insrc/utils/__mocks__/yoga-wasm-cjs.ts— a Jest mock that is never bundled; everyinnerHTMLwrite inusePullToRefresh.tsassigns a static SVG constant, no user input. Capgo CLI pinned from@latestto8.42.4.
Order of operations: merge BE peanutprotocol/peanut-api-ts#1461 first (after the manual prisma migrate deploy), then this one.
Manual smoke on the preview is still outstanding per the release page — receipt PDF download, activity rendering, send-link create/claim/cancel, residence KYC, and QR pay + withdraw are the flagged risk areas.
|
@jjramirezn tagging you on the FE half of the Sprint 158 release — pairs with peanutprotocol/peanut-api-ts#1461. Approved and green at Smoke list still open — receipt PDF download, activity rendering, send-link create/claim/cancel, residence KYC, QR pay + withdraw. The QR-pay withdraw triage (#2869) pairs with your #1457 on the BE side. |
Release of
dev-snapshot-2026-08-28(6bbb98c5e) intomain. 254 commits, 404 files.What this is
The frozen release point, cut from
devafter themain → devback-merge (#2874) landed.mainis strictly contained in this snapshot (git rev-list --count dev-snapshot-2026-08-28..main= 0), so the merge carries no conflicts.The shipped native build came from this exact commit.
v1.1.0is tagged here and is already on TestFlight and the Playinternaltrack, with the matching1.1.0production bundle published to Capgo. Web and native therefore ship the same tree.Deliberately excluded
devhas moved 338 commits / 53 merges past this point since the freeze — principally the design-system release (#2813) and app registration/onboarding (#2876). Those are not in this release by design; that is what the snapshot exists to prevent.Fixes applied during the back-merge review
Bot review of #2874 raised findings against the hotfix work
maincarried. Applied after verifying each:lib/content.ts— locale fallback returned the first existing locale whileexistingPageLocalepicks the first published one, so links pointed at pages the renderer discarded.SumsubKycWrapper—handleResubmittednever learned about multi-level (added tohandleSubmittedin Aug), so an EU/LATAM retry closed the SDK before the follow-up questionnaire, stranding the applicant with a level owed.useMultiPhaseKycFlow—KYC_REJECTEDnow reports once per attempt rather than once per status value;liveKycStatusstaysREJECTEDacross a retry, so keying on it alone either duplicated the event or swallowed a real second rejection.kernelClient.context—paymasterUrlwas asserted before the sponsored flag was read; bundler validation now runs on both paths, paymaster only where a paymaster transport is built.robots.ts— computed its production gate fromBASE_URL, whose production fallback made an env-less preview serve the production crawl policy. Now reads the raw env, matchinglayout.tsx.chip-pr-review.yml— droppedCONTRIBUTORfrom the manual dispatch gate (permanent after one merged commit, survives access revocation). Known cost recorded in the file header.Two findings were refuted rather than applied, with reasoning in the #2874 thread: the
ONESIGNAL_WEBHOOKrequired-key finding (the native adapter never reads that key) and the/supportlocale finding (contradicts two explicit tests).Verification
6bbb98c5e.Not covered
The multi-level KYC retry path has unit coverage but has not been exercised against real Sumsub. It is on TestFlight and Play internal for exactly that reason — worth walking a rejected-then-retried EU/LATAM verification before promoting to production.