feat(bridge): gate KYC initiation on phone-country eligibility (ERPNext-managed allowlist) - #502
Open
islandbitcoin wants to merge 4 commits into
Open
feat(bridge): gate KYC initiation on phone-country eligibility (ERPNext-managed allowlist)#502islandbitcoin wants to merge 4 commits into
islandbitcoin wants to merge 4 commits into
Conversation
…xt-managed allowlist)
On 2026-09-01 a wave of reward-hunting signups from countries Bridge cannot
issue Flash a USD virtual account for started KYC within minutes of
registering. Bridge approved them and then refused the virtual account
("The customer is not authorized to create USD Virtual Accounts"): the user
did the work, got a green check, and still had nothing.
bridgeInitiateKyc now asks the one question that predicts that outcome
before sending anyone through KYC: is the user's PHONE country one Bridge
can serve? The phone country is the Twilio Lookup country stamped at signup
(`user.phoneMetadata.countryCode`), with the phone number's parsed region as
the fallback; it is the hardest signal to fake (the wave typed "Jamaica" into
the address form while verifying real Indian and Nigerian SIMs). Ineligible
users get an honest answer up front instead of a KYC they cannot use:
"US virtual accounts aren't available in {country} yet."
The allowed set is ops-managed: the ERPNext "Allowed Country" doctype rows
with `flash_allowed` ticked (/app/allowed-country), read through the same
60s-memoised pattern as fee-discounts.ts. When ERPNext cannot be read the
reader falls back to `bridge.kycGate.countryAllowlist.defaultCountries`
(never "allow everyone", never "deny everyone"). Default deny: a country not
in the set is not allowed.
There is deliberately NO account-age rule (an earlier revision of this branch
had one): a real user must be able to start KYC the moment they sign up.
`requireVerifiedEmail` stays available but is now off by default.
Config (absent keys take these defaults):
bridge.kycGate.requireVerifiedEmail: false
bridge.kycGate.countryAllowlist.enabled: true
bridge.kycGate.countryAllowlist.defaultCountries: JM TT BB BS DO KY AG DM
GD KN LC VC BZ GY SR AW CW SX BM TC VG AI MS US CA GB MX SV SN KE
KE is seeded on instruction although Bridge's published table marks Kenya
ineligible for ACH/FedWire; HT and CU are absent (no ACH for HT, CU
prohibited). Every denial is logged at warn with accountId/countryCode/rule
and stamped on the span (bridge.kyc_gate.*).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
islandbitcoin
force-pushed
the
feat/bridge-kyc-gate
branch
from
September 1, 2026 22:49
f7613b7 to
3172f1a
Compare
…its defaults Review fixes for #502. - countryAllowlist.source ("config" | "erpnext", default "config"). The ERPNext doctype as seeded on frappe-flash-admin main ticks flash_allowed for 165 countries, NG and IN included; reading it before the reseed (frappe-flash-admin#76) has run would admit the wave this gate exists to stop. Defaulting to the config list makes the two PRs order-independent; the flip to ERPNext is a deliberate configmap step after verifying the reseed. - Calling-code fallback for numbers libphonenumber parses but cannot attribute (~340 assigned NANP area codes: +1 924/472/861; the UK 07700 900xxx range). Such a user has a verified phone on file; the old gate told them to add one. The gate now resolves every region the calling code could denote and passes if any is allowed, the same fallback checkAuthCodeDestination makes. countriesForCallingCode is lifted out of request-code.ts into @domain/users/phone-regions so both gates share one candidate set. bridge.kyc_gate.country_source is stamped on every evaluation so ops can see the fallback rate. - Add the five NANP US territories (PR, VI, GU, AS, MP) to the default list: Twilio Lookup reports them by territory, not as US, and they are US persons Bridge can serve. The list now covers every NANP region. - One toAlpha2 in @services/frappe/coerce replaces the three copies of the trim/upper/^[A-Z]{2}$ rule. - Tests: pin the kycGate schema defaults (enabled, email rule off, source "config", 35 codes, territories, every NANP region, no IN/NG/HT/CU); cover ErpNext.getAllowedCountries (query shape, non-array data, axios reject); cover toAlpha2 and countriesForCallingCode; drop the "no age rule" case that was a byte-for-byte duplicate of the first. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
…dicts Review fixes for #502. `resolvePhoneCountries` trusted `phoneMetadata.countryCode` unconditionally, but that stamp is written only at account creation and both public phone mutations carry it under a new number: `userPhoneDelete` spreads `...user` so the metadata survives, and `userPhoneRegistrationValidate` writes the new number with no fresh Twilio Lookup. A JM signup that re-registers a Nigerian SIM therefore carried `countryCode: "JM"` over `phone: "+234…"` and the gate waved it through to the virtual-account refusal it exists to stop. The stamp is now accepted only while the number on file cannot contradict it: no resolvable number, the same region, or an unattributable number on a calling code that includes the stamped region. Otherwise the number decides and the resolution carries `lookupStale: true`, stamped on the span as `bridge.kyc_gate.lookup_stale` and on the denial log so ops can count the rate. Refreshing the stamp on phone change is the deeper fix; not done here. Also cite the lockfile's libphonenumber-js (1.12.25 — what `--frozen-lockfile` ships and CI tests) in the spec instead of a drifted local 1.13.11; both versions were verified to behave identically on every number the tests use. Tests: stale JM stamp over +234… denies naming Nigeria with the stale flag stamped; stale NG stamp over a JM number passes on the number; JM stamp over an unattributable +1 stands; stamp with no number stands; the stale calling-code fallback keeps the candidate set. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
islandbitcoin
pushed a commit
to lnflash/frappe-flash-admin
that referenced
this pull request
Sep 2, 2026
…allowlist Mirrors the flash `bridge.kycGate.countryAllowlist.defaultCountries` seed after the Simon review of lnflash/flash#502: Twilio Lookup returns these codes for +1 787/939, 340, 671, 684 and 670 numbers, and Bridge serves their residents as US persons, so they must not be told "US virtual accounts aren't available in Puerto Rico yet". Adds seed rows for GU/AS/MP (PR/VI already existed) and bumps the contract test to the 35-code list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
On 2026-09-01 a wave of reward-hunting signups from countries Bridge cannot issue Flash a USD virtual account for started Bridge KYC within minutes of registering (81 initiations / 15 min at peak). Bridge approved them and then refused the virtual account — 29
"The customer is not authorized to create USD Virtual Accounts"refusals in 3 h. The user did the work, got a green check, and still had nothing.An earlier revision of this branch gated on a minimum account age. That rule is dropped: it directly punishes real users who sign up precisely to get their US virtual account right away (Jabari, 2026-09-01). A delay doesn't prevent the bad outcome above, it only postpones it.
What
bridgeInitiateKycnow asks the one question that predicts the refusal, before sending anyone through KYC: is the user's phone country one Bridge can serve?user.phoneMetadata.countryCode— the Twilio Lookup country stamped at signup. Fallback: the phone number's parsed region (libphonenumber-js). When libphonenumber can parse the number but not name its region (~340 assigned NANP area codes are absent from the lockfile's libphonenumber-js 1.12.25 metadata (whatyarn install --frozen-lockfileships and CI tests) — +1 924, +1 472, +1 861 — and the UK 07700 900xxx range), the gate falls back to every region the calling code could denote and passes if any of them is allowed, the same fallbackcheckAuthCodeDestinationmakes (fix(auth): reject unsupported OTP destinations before paying Twilio #496). Only a number with no phone, or an unparsable one, isBRIDGE_KYC_PHONE_REQUIRED. This is the hardest signal to fake at signup: the wave typed "Jamaica" into the address form while verifying real Indian and Nigerian SIMs. It is also the easiest to go stale afterwards: the stamp is written once, at account creation, anduserPhoneDelete/userPhoneRegistrationValidatereplace the number without refreshing it — a JM signup that re-registers a Nigerian SIM carriescountryCode: "JM"overphone: "+234…". So the gate trusts the stamp only while the number on file cannot contradict it (no resolvable number, the same region, or an unattributable number on a calling code that includes the stamped region); otherwise the number decides and the evaluation is stampedbridge.kyc_gate.lookup_stale: true.bridge.kycGate.countryAllowlist.source:"config"(default):defaultCountriesis the list. No ERPNext read. This is what makes the PR safe to deploy on its own (see Deploy order)."erpnext": ERPNext doctypeAllowed Country, rows withflash_allowedticked; ops toggle countries at /app/allowed-country with no release. Read viaGET /api/resource/Allowed Country?filters=[["flash_allowed","=",1]]&fields=["alpha2_code","country_name","flash_allowed"]&limit_page_length=1000, memoised 60 s (successes and failures, same pattern asfee-discounts.ts).flash_allowedis re-checked on each row so a lost filter can't widen the list. When ERPNext can't be read (or returns no allowed rows) the reader falls back todefaultCountries— never "allow everyone", never "deny everyone".warnwith{ accountId, countryCode, countrySource, rule }and stamped on the span (bridge.kyc_gate.denied/rule/country);bridge.kyc_gate.country_source(lookup|number|calling-code|none) is stamped on every evaluation so the calling-code fallback rate is visible, andbridge.kyc_gate.lookup_stale: true(also on the denial log aslookupStale) whenever the number on file overruled the signup stamp, so that rate is visible too. An ERPNext fallback is logged atinfo.Deploy order (read this)
The ERPNext doctype as seeded on frappe-flash-admin
maintoday ticksflash_allowedfor 165 countries — NG and IN among them. The reseed to the 30-country list is lnflash/frappe-flash-admin#76 (feat/bridge-kyc-country-allowlist), not yet merged. Reading ERPNext before that patch has run would admit exactly the wave this gate exists to stop.That is why
sourcedefaults to"config". The two PRs are order-independent; flipping to ERPNext is a deliberate later step:bench migrateruns the reseed.GET /api/resource/Allowed Country?filters=[["flash_allowed","=",1]]&limit_page_length=1000returns the expected ~35 rows and no NG/IN.bridge.kycGate.countryAllowlist.source: erpnextin the configmap.Config (absent keys take these defaults)
bridge.kycGate.requireVerifiedEmailfalse(rule kept, off by default)bridge.kycGate.countryAllowlist.enabledtruebridge.kycGate.countryAllowlist.source"config"bridge.kycGate.countryAllowlist.defaultCountriesJM TT BB BS DO KY AG DM GD KN LC VC BZ GY SR AW CW SX BM TC VG AI MS US PR VI GU AS MP CA GB MX SV SN KESeed = Caribbean + US/CA/GB + MX, SV, SN (Dakar), KE, per instruction, plus the five US territories in the NANP (PR, VI, GU, AS, MP): Twilio Lookup reports them by territory (+1 787 →
PR), not asUS, and they are US persons Bridge can serve; without them a Puerto Rico user gets "US virtual accounts aren't available in Puerto Rico yet." With them the default list covers every NANP region (pinned inschema.spec.ts). Caveat: Bridge's published country table marks Kenya ineligible for US ACH/FedWire; it is seeded on instruction and ops can untick it in ERPNext. HT and CU are deliberately absent (no ACH for HT; CU prohibited).User-facing messages (shown verbatim by the app)
BRIDGE_KYC_COUNTRY_NOT_SUPPORTED— "US virtual accounts aren't available in {Country} yet." (country name when the runtime can name it, else the code; for an unattributable number on a shared calling code with no allowed region, the candidates are named — "in Kazakhstan or Russia yet." — or, past three, "in your country yet.")BRIDGE_KYC_PHONE_REQUIRED— "Add and verify a phone number before starting identity verification." (no phone, or unparsable)BRIDGE_KYC_EMAIL_NOT_VERIFIED— "Verify your email address before starting identity verification." (only when the email rule is switched on)Files
src/app/bridge/kyc-gate.ts(purecheckBridgeKycEligibility,resolvePhoneCountries,assertBridgeKycEligible),src/domain/users/phone-regions.ts(countriesForCallingCode, lifted out ofrequest-code.tsso both gates share one candidate set),src/services/frappe/allowed-countries.ts(reader with thesourceswitch),src/services/frappe/coerce.ts(toAlpha2— one alpha-2 rule for ERPNext rows, config entries and Lookup results),src/services/frappe/ErpNext.ts(getAllowedCountries),src/services/frappe/errors.ts,src/services/bridge/errors.ts,src/graphql/error-map.ts,src/graphql/public/root/mutation/bridge-initiate-kyc.ts,src/config/schema.ts+schema.types.d.ts; specs for the gate, the reader, the resolver, the error map,ErpNext.getAllowedCountries,toAlpha2,countriesForCallingCode, and the schema defaults.Verification
Follow-ups (not in this PR)
phoneMetadatawith a fresh Twilio Lookup insideverifyPhone/userPhoneRegistrationValidateso the stamp cannot go stale on a phone change. The gate's number cross-check is the guard until then.🤖 Generated with Claude Code
https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8