Skip to content

fix(auth): reject unsupported OTP destinations before paying Twilio - #496

Merged
islandbitcoin merged 9 commits into
mainfrom
fix/otp-country-gate
Aug 26, 2026
Merged

fix(auth): reject unsupported OTP destinations before paying Twilio#496
islandbitcoin merged 9 commits into
mainfrom
fix/otp-country-gate

Conversation

@bobodread876

@bobodread876 bobodread876 commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

What happened

On 2026-08-25 our Twilio Verify service was hit with an SMS-pumping attack: 1,569 messages / $439.79 in one day, against a baseline of 1–10 OTPs per day (~$1/day; the entire previous month was $18.70). Traffic was concentrated in countries where Flash has no users — Uzbekistan, Turkey, Russia, Belarus, Armenia, Bosnia, Zambia, Ecuador and others — in sequential number blocks, converting at roughly 1%.

Why our existing defenses didn't stop it

Both were working as designed:

  • Captcha is enforced. Every entry point routes through requestPhoneCodeWithCaptcha, which validates Geetest before anything else, and Geetest does not fail open.
  • Rate limits are enforced. 16/hour per IP, 8/20min per phone number.

The attacker rotated IPs and solved captcha (cheap at scale), staying under every limit. 16/hour × ~100 IPs ≈ 1,600 — almost exactly the observed burst.

The real gap is ordering. The destination country is only checked by PhoneMetadataAuthorizer, which runs at login completion (src/app/authentication/login.ts) and in rewards — after the message has been sent and billed. Every fraudulent OTP went to a country we don't serve and we paid for it anyway.

Separately, smsAuthUnsupportedCountries / whatsAppAuthUnsupportedCountries already existed but were only used to filter the client's country picker (getSupportedCountries). The API never checked them, so the app hid unsupported countries while the endpoint still sent to them.

Changes

  • Enforce the existing per-channel supported-country config server-side in requestPhoneCodeWithCaptcha and requestPhoneCodeForAuthedUser, immediately before the provider call. New pure helper isAuthChannelSupportedForCountry in @domain/authentication.
  • Reject unparseable numbers as InvalidPhoneNumber (not as a blocked country) instead of forwarding them to Twilio.
  • Two new config keys, smsAuthBlockedCountries / whatsAppAuthBlockedCountries, seeded with 25 attack-origin countries. Every one sent auth-code traffic with zero conversions across the full Twilio retention window. Countries with even one real signup (JM, US, NG, IN, GB, CA, DE, GH, KY, BJ, RW, SD, CD, MV, BD, BE, UG, TT, ML, CO, SK) are excluded by design — this is a fraud control, not a market policy. Plausible future markets with no conversions (FR, ZA, KE, PH, MY, EG, SA) were deliberately left unblocked.
  • The block list is deliberately NOT the picker list. smsAuthUnsupportedCountries / whatsAppAuthUnsupportedCountries keep their empty defaults and keep governing globals.supportedCountries only. A country hidden from the picker cannot be selected in the app at all, so its numbers never reach the endpoint and the existing-user carve-out below would be unreachable for exactly the users it exists to protect. Enforcement and presentation are separate keys on purpose.
  • Existing accounts are never locked out. requestPhoneCodeWithCaptcha serves login as well as signup, so a blocked country falls through to a Kratos identity lookup (IdentityRepository().getUserIdFromIdentifier, the same store login.ts resolves against): a phone that already belongs to an account still gets its login code. The lookup costs no Twilio spend, fails closed on any error, and is bounded by its own tiny per-IP probe budget (2/h, refunded on a confirmed account) so the free existence answer cannot be swept. The country stays selectable in the picker, so the carve-out is actually reachable from the app. requestPhoneCodeForAuthedUser has no such carve-out — binding a phone to an authed account is always a new registration of that number.
  • Blocks are observable. Every rejection emits baseLogger.warn plus a verification / destination-blocked ops event (phone masked), so blocked-country counts land in the existing Discord ops feed and the list can be tuned from data instead of guesswork.
  • Case-insensitive matching. The lists are raw configmap strings that src/config/yaml.ts casts to CountryCode[] without validating, so - uz would previously have disabled both the gate and the picker filter silently.
  • The channel is normalized inside the gate. POST /auth/phone/code forwards req.body.channel verbatim ("SMS" / "WHATSAPP") while the two GraphQL resolvers lowercase it, so that route would have been gated against the wrong list the moment the two lists diverge.
  • PhoneCountryNotAllowedError now has its own case in src/graphql/error-map.ts → "Phone number is not from a valid region" (ValidationInternalError), mirroring RestrictedRegionPhoneProviderError. It previously fell into the catch-all and surfaced a deliberate policy rejection as "Unexpected error occurred, please try again or contact support".
  • InvalidPhoneNumber was in that same catch-all → "Phone number is not a valid phone number", alongside its provider-side twin. The catch-all message interpolates error.message, which for this error is the submitted number, so a malformed number was echoed back to the client inside an "unexpected error" string. Also fixes the pre-existing checkedToPhoneNumber path.
  • Lower the requestCodePerIp schema default to 8/hour. A real user needs one or two.
  • A number libphonenumber parses but cannot attribute to a region (340 of the 800 assigned NANP area codes are absent from the pinned 1.12.25 metadata, including in-service US overlays like +1 983 and +1 738) is gated on every region its calling code could denote, not rejected. +1 passes because no NANP region is blocked; +7 still fails closed because RU is. Only a number that does not parse at all is refused, under its own destination-unparsable telemetry phase.

Deploy notes

Companion PR: lnflash/deployments#192 — required. tf-modules/flash/flash-values.tmpl.yaml hard-sets the whole rateLimits object for dev/test/prod, which suppresses the schema default, so the requestCodePerIp change here is inert in every deployed environment until that PR merges.

The country lists are not set anywhere in lnflash/deployments (verified by grep), so the seeded schema defaults do apply on deploy. Re-verify before rollout if that changes — a configmap value always wins over the default. The enforcement lands either way.

Merge checklist

  • Merge lnflash/deployments#192 with (or before) this, or the rate-limit bullet above is untrue in prod.
  • Count existing accounts by dialing prefix for the 25 blocked codes against prod (mongo accounts joined to kratos identities) and paste the counts here. "No Twilio conversion inside the retention window" is not the same claim as "no account exists" — a diaspora user who registered before the window is not visible in the Twilio data. The carve-out plus the picker split means a non-zero count is not a lockout, but any country with real accounts should still come off the list, since those users would otherwise be gated on every new phone binding.
  • The country lists are two keys now. If a configmap ever sets them, set *AuthBlockedCountries for enforcement — setting *AuthUnsupportedCountries instead hides the country from the picker and takes the carve-out away with it.

Testing

  • test/flash/unit/app/authentication/request-code-destination.spec.ts: covers both entry points — provider never called for a blocked country, per-channel lists are independent, channel casing normalized, lowercase config entries still match, unparsable numbers rejected as InvalidPhoneNumber under their own telemetry phase, NANP overlays libphonenumber cannot attribute still delivered while an unattributable +7 still fails closed, paged kinds expiring after a quiet period without re-paging mid-flood, rejection happens even when captcha passes, blocked requests emit the log + ops event, allowed requests emit neither, existing users in a blocked country still get a code, repository failure fails closed, and requestPhoneCodeForAuthedUser has no carve-out and fires no otp-sent event when blocked.
  • test/flash/unit/config/schema.spec.ts: asserts the shipped defaults against the real configSchema with no @config mock — both block lists are 25 uppercase ISO codes containing UZ and TR and none of the 21 converting countries, both picker lists are empty, and requestCodePerIp is 8/h.
  • test/flash/unit/config/rate-limits.spec.ts: pins getRequestCodeBlockedCountryPerIpLimits() to 5 points / 1h / 1h block — the shared-CGNAT reason the block duration is not the 24h the other auth limiters use.
  • test/flash/unit/app/authentication/supported-countries.spec.ts: runs against the real config and asserts UZ/TR/RU are still offered in the picker, i.e. the carve-out is reachable from the app.
  • test/flash/unit/domain/authentication/index.spec.ts: allow, per-channel block, both-channel block, empty lists, and lowercase config entries for both isAuthChannelSupportedForCountry and getSupportedCountries.
  • test/flash/unit/graphql/error-map.spec.ts: pins PhoneCountryNotAllowedError and InvalidPhoneNumber to validation messages, not the catch-all, and asserts the submitted number is not echoed back.
  • Full unit suite: 2281 passed / 208 suites. yarn tsc-check, yarn tsc-check-noimplicitany, yarn eslint-check, yarn build, yarn madge-check, yarn check-yaml and typos all clean.

Follow-ups (not in this PR)

  • Twilio-side: geo-permissions allowlist, Verify rate limits, Fraud Guard (raised to Max during the incident).
  • Consider a per-country rate limit — market-agnostic, catches the next pumping run without needing to predict which country it comes from.
  • Watch the destination-blocked ops feed for a week and prune any country that shows real-looking traffic.

🤖 Generated with Claude Code

https://claude.ai/code/session_018sCFQXDf4s6f1zsrpmaShL

islandbitcoin and others added 6 commits August 25, 2026 12:11
The 2026-08-25 SMS-pumping attack cost $439.79 in one day (1,569 messages
vs a ~$1/day baseline). Captcha and rate limits were both enforced; the
attacker rotated IPs and solved captcha, staying under 16/hour/IP.

The gap was ordering: the destination country is only checked by
PhoneMetadataAuthorizer at login completion and rewards, i.e. after the
message has already been sent and billed. Every fraudulent OTP went to a
country Flash does not serve and was paid for regardless.

- enforce the existing per-channel supported-country config in
  requestPhoneCodeWithCaptcha and requestPhoneCodeForAuthedUser, before
  the provider call. Previously these lists only filtered the client's
  country picker and were never checked server-side.
- seed both lists with the attack's origin countries: every one sent
  auth-code traffic with zero conversions across the full Twilio
  retention window. Countries with even one real signup are excluded by
  design; this is a fraud control, not a market policy.
- halve requestCodePerIp to 8/hour. 16/hour x ~100 rotated IPs matches
  the observed burst almost exactly, and a real user needs one or two.

Unparseable numbers are rejected rather than forwarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sCFQXDf4s6f1zsrpmaShL
…king

Review fixes on top of the destination gate:

- checkAuthCodeDestination now lowercases the channel itself. POST
  /auth/phone/code forwards req.body.channel verbatim ("SMS"/"WHATSAPP")
  while the GraphQL resolvers lowercase it, so a WhatsApp request on that
  route was gated against the SMS list. Harmless only while the two lists
  are identical, which is exactly what will stop being true.
- isAuthChannelSupportedForCountry and getSupportedCountries compare
  case-insensitively. The lists are raw configmap strings with no
  validation, so `- uz` silently disabled both the fraud control and the
  picker filter.
- an unparseable number returns InvalidPhoneNumber, not
  PhoneCountryNotAllowedError. The country is unknown, not disallowed;
  the old error told the user to contact support and poisoned any later
  count of which countries the gate rejects.
- PhoneCountryNotAllowedError gets its own case in error-map instead of
  falling into the catch-all, which rendered a deliberate policy
  rejection as "Unexpected error occurred, please try again or contact
  support". Mirrors RestrictedRegionPhoneProviderError.
- every block emits baseLogger.warn plus a "destination-blocked" ops
  event, so blocked-country counts land in the existing ops feed and the
  list can be tuned from data rather than guesswork.
- the login/signup path keeps an existing-user carve-out: a phone already
  in the users collection still gets its code even if its country is on
  the list, so no established account is permanently locked out by a
  control aimed at unregistered traffic. The lookup is local, costs no
  Twilio spend, and fails closed. requestPhoneCodeForAuthedUser has no
  carve-out — binding a phone there is always a new registration.

Tests: requestPhoneCodeForAuthedUser now has its own gate coverage
(blocked country, no otp-sent event, per-channel lists, no carve-out);
config/schema.spec asserts the shipped defaults against the real
configSchema with no @config mock, so reverting the seeded lists to []
fails; domain and app specs cover lowercase config entries; error-map
spec pins the dedicated mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
…atch-all

The country-gate review assumed InvalidPhoneNumber "already maps to a
clean client error". It did not — it sat in the same UnexpectedClientError
bucket PhoneCountryNotAllowedError was just pulled out of, so a malformed
number told the user to retry and contact support, and the catch-all
message interpolates error.message, echoing the submitted number back.

Both checkedToPhoneNumber and the new auth-code destination check produce
this error, so both now render "Phone number is not a valid phone number"
alongside the provider-side twin InvalidPhoneNumberPhoneProviderError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
…out true and visible

Four review findings on the destination country gate, all in the
existing-user carve-out and its telemetry.

Ask Kratos, not Mongo, whether a number exists. Whether a number can log
in is decided by the identity store — login.ts resolves it with
getUserIdFromIdentifier and skips onboarding when it exists — while the
Mongo user doc is written afterwards by a /registration webhook that can
fail with the identity already created. Asking findByPhone would refuse a
login code forever to an account that logs in fine today: the exact
lockout the carve-out exists to prevent. IdentifierNotFoundError blocks;
any other Kratos fault blocks too, so the control still fails closed.

Budget the carve-out per IP. For a blocked destination the response now
differs by whether the number holds an account, and probing is free
because nothing is sent — the economic brake that bounds every other
enumeration attempt on this endpoint does not apply. A dedicated
requestCodeBlockedCountryPerIp bucket (2/h, 24h block) is consumed before
the lookup, so a sweep runs out after two tries and gets the same
PhoneCountryNotAllowedError as any other blocked number. A confirmed
account refunds its point, so a real customer abroad is never spent out
of their own login code by asking twice.

Coalesce the telemetry. notifyOpsEvent feeds one 50-slot FIFO shared with
cashout/deposit/upgrade/transfer that drops its oldest entries: an embed
per rejection would evict the rest of the ops feed during exactly the
incident this telemetry is for. The first rejection of each
(phase, channel, country) still pages immediately, everything after is
counted and flushed as one summary per 5-minute window. The per-request
log line is unchanged.

Report the carve-out. The block list is meant to be tuned by watching
this feed for real traffic, and the carve-out is precisely the real
users — reporting it locally only guaranteed the feed could say nothing
but "no real users here". Served users now emit
destination-blocked-existing-user, and a burnt-out probe budget emits
destination-blocked-probe-limit, so a sweep is visible rather than
silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
…hour, unbreak spell check

Round-3 review residuals on #496, plus the two spell-check failures.

BLOCKING — the coalescing fix was bypassable by an unvalidated request
field. `normalizedChannel` was `String(channel).toLowerCase()`, i.e.
attacker-controlled: POST /auth/phone/code passes req.body.channel
through verbatim. That string is baked into the coalescing key
(`${phase}|${channel}|${countryCode}`), so every distinct value was a
fresh "kind" — missed pagedBlockedKinds, paged immediately, never
reached a bucket, and left a permanent Set entry in a long-lived
process. Verified: 20 requests with channel sms0..sms19 produced 20
notifyOpsEvent calls, not 1 — the exact eviction of the 50-slot feed
shared with cashout/deposit/upgrade/transfer that the round-2 fix
existed to prevent. Collapsed to the ChannelType enum, which is the
fallback isAuthChannelSupportedForCountry already applies, so no gating
decision changes; only the key space is bounded to two values. The new
test fails with 20 calls against the old line and passes with 1 — I
checked both ways.

blockDuration 24h -> 1h on requestCodeBlockedCountryPerIp. It is keyed
on x-real-ip, and a large share of our users reach us from behind
carrier-grade NAT, where one egress address covers many subscribers.
Two sweep probes from that address cost every real customer behind it a
full day of their own login codes. The bound that limits a sweep is
2 probes/IP/hour and is unchanged; this only sets how fast a shared-IP
false positive heals.

The probe-budget refund now logs its failure. limiter.reward RETURNS
its error rather than throwing, so swallowing it made "a real customer
abroad is never spent out of their own login code" true only while
Redis is healthy — and the resulting lockout had nothing in the logs
pointing here.

Spell check: "unparseable" -> "unparsable" in a test name. And "BA" is
pinned in typos.toml, NOT corrected — it is Bosnia and Herzegovina's
ISO code in the blocked-country list, flagged as a typo of "BY"/"BE"
while BY is already in that same list. Taking the suggestion would
silently change which country the gate blocks and duplicate an entry.

66 auth tests pass, tsc + eslint + typos clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
…e paged kinds

Review fixes on the OTP country gate.

- libphonenumber cannot name a region for every number it parses: 340 of the
  800 assigned NANP area codes are missing from the pinned 1.12.25 metadata,
  including in-service US overlays (+1 738, 924, 983, 472). The gate turned
  that `undefined` into a hard InvalidPhoneNumber, so signup AND login were
  dead for real US customers on those codes — in a market on no block list at
  all. Parse once, reject only a number that does not parse, and otherwise fall
  back to every region the calling code could denote: +1 passes because no NANP
  region is blocked, +7 still fails closed because RU is.

- Split the picker filter from the fraud control. Seeding
  smsAuthUnsupportedCountries / whatsAppAuthUnsupportedCountries with the 25
  attack-origin codes removed them from `globals.supportedCountries`, so no app
  user could select +998 at all and the server-side existing-user carve-out was
  unreachable from the client — "existing accounts are never locked out" was
  false for every app user. Enforcement now reads new smsAuthBlockedCountries /
  whatsAppAuthBlockedCountries keys; the picker lists go back to empty defaults.

- Pin the two numbers this change exists to set: the requestCodePerIp schema
  default (8) and getRequestCodeBlockedCountryPerIpLimits (2 / 1h / 1h). Both
  were assertable nowhere, and the destination spec mocked the block duration
  to the 86400 that 75cfb19 removed.

- Report an unparsable number under its own `destination-unparsable` phase, so
  client input noise stops inflating the counter the block list is tuned from
  and stops burning the blocked-country one-shot page.

- Widen requestPhoneCodeForAuthedUser to `true | ApplicationError`. The body
  already returned RateLimiterExceededError, PhoneAlreadyExistsError and
  NotImplementedError, none of which were in the declared union.

- Expire paged kinds. `pagedBlockedKinds` was a Set only tests ever cleared, so
  a kind paged once never paged again for the pod's lifetime; it is now a
  key -> last-seen map pruned on flush after six windows, which keeps a
  sustained flood to one page but makes a wave after a quiet period news again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
@bobodread876

Copy link
Copy Markdown
Collaborator Author

Merge-condition evidence

1. Per-country account count against prod — done. Kratos identities on prod, grouped by dialing prefix (no individual numbers left the cluster), cross-referenced against session activity:

Accounts across the 25 blocked countries 51 (18 countries have ≥1)
Active in the last 90 days 2 — TZ ×1, UZ ×1
Remaining 16 countries zero sessions in 90d; newest identity 2026-03

Top counts: TR 8, CM 7, RU 4, SN 4, IL 4, TZ 4, UA 3.

So the gate's real-world lockout exposure is two accounts, and both are exactly what the existing-user carve-out was built for — now that the picker split (4ed233858) makes that carve-out reachable from the app at all. Recommendation: keep all 25. Dropping countries on 51 dormant identities would give back most of the fraud surface to protect users who aren't there.

Caveat worth recording: +7 matches Kazakhstan as well as Russia, so some of those 4 may be KZ (not blocked). All 4 are inactive, so it doesn't change the decision.

2. requestCodePerIp 16 → 8 — companion PR open: lnflash/deployments#193. Confirmed the finding: tf-modules/flash/flash-values.tmpl.yaml hard-sets the whole rateLimits object for dev/test/prod, so the schema-default change here is inert on its own. Either merge order is safe.

bobodread876 and others added 3 commits August 25, 2026 16:11
…over the flush

Review fixes for the OTP country gate.

- Enforce the invariant the +1 path rests on. The unattributable-region
  fallback gates a number against every region its calling code could denote,
  so one NANP entry on the block list breaks signup for real US customers on
  the ~340 assigned area codes the pinned metadata does not carry. Pinned in
  schema.spec.ts, stated in blocked-countries.ts, and re-checked at startup
  against the MERGED configmap by reportAmbiguousBlockedCountries — error level
  for a NANP region, warn for other collateral (today: KZ behind RU on +7,
  which is now pinned so the next one has to be argued for).

- Raise the blocked-country probe budget from 2 to 5 points/IP/h. At 2, a real
  UZ account holder who mistypes twice — or the second person behind an office
  NAT or CGNAT egress — is denied their own login code for an hour with no
  attacker involved. A sweep is equally dead at 5/IP/h, and the attacker in the
  2026-08-25 incident drove ~100 rotating IPs, so the per-IP ceiling was never
  the binding constraint.

- Cover the window timer. Every test drained the buckets by hand, so deleting
  the scheduleBlockedReportFlush() call left the suite green while the summary
  embed never fired in production. Fake-timer tests now assert the summary
  lands on its own, once per window.

- Flush pending counts on SIGTERM/SIGINT. Rolling deploys and OOM kills were
  discarding up to five minutes of blocked-destination counts, and a pod under
  attack load is the likeliest to be cycled. The hook is installed on first
  coalesce and hands the signal back after a bounded wait, so it never changes
  how a process dies.

- Give each blocked-country config key its own default array instance. Ajv's
  useDefaults assigns by reference, so the two keys and the schema object were
  one live array in any environment setting neither.

- POST /auth/phone/code sent `{"error":{}}` — an Error's name and message are
  non-enumerable — hiding every message the error map produces. Send
  mapError(result).message, as /phone/login already does.

- Move SMS_PUMPING_HIGH_RISK_COUNTRIES to its own file and scope the typos
  suppression to that path, instead of disabling the BA -> BY/BE suggestion
  repo-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
Review fixes.

The SIGTERM flush hook re-armed itself after firing. onShutdownSignal
unhooks and then waits up to SHUTDOWN_FLUSH_TIMEOUT_MS before re-raising,
and Apollo's drain keeps existing keep-alive connections served while it
stops — so blocked requests keep arriving in exactly that window, and any
coalesce landing there ran scheduleBlockedReportFlush() ->
hookShutdownFlush() and put the listener straight back. The re-raised
signal was caught again, flushed again, waited another 2s and re-raised:
~15 loops inside a k8s 30s grace period, then SIGKILL with in-flight
requests dropped and the counts lost anyway. Make it a one-way latch via
`shuttingDown`, reset in resetBlockedDestinationReporting so tests stay
isolated, and add a test that emits SIGTERM, drives more blocked traffic
and asserts the listener count stays 0 and the signal is handed back once.

The "skips a code libphonenumber cannot resolve" test never entered the
branch it named: getCountryCallingCode("XK") returns "383" in the pinned
libphonenumber-js 1.12.25, so XK was silent because it is the sole region
on +383, not because the catch fired. Drive that test with ZZ, which does
throw, keep XK as its own case under an accurate name, and correct the
three comments that asserted XK was unknown to the metadata.

Also correct the opsEventsSettled docstring: it is the drain barrier for
graceful shutdown as well as for tests, and must keep returning the
in-flight `draining` promise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
Review residual: the invariant comment cited
`assertNoAmbiguousBlockedCountries`, which does not exist. The real
function is `reportAmbiguousBlockedCountries`, and it LOGS rather than
throws. An operator adding DO to the configmap would read "assert",
believe a bad entry is caught hard at startup, and never look for the
error line that is the control's only actual backstop — while a Ready
pod quietly rejects every US number on an unattributed NANP overlay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
@islandbitcoin
islandbitcoin merged commit f933c06 into main Aug 26, 2026
15 checks passed
bobodread876 added a commit that referenced this pull request Aug 26, 2026
Pre-existing failure on main, unrelated to this PR but blocking it:
the ObjectId in src/scripts/setup-rewards-account.ts ends in "ba",
which typos reads as a misspelling of "by"/"be".

Pinned by shape (24 hex chars) rather than by allowing the token "ba"
globally -- "ba" really can be a typo in prose, and the id is data
whose "correction" would point at a different document. Same reasoning
as the BA country-code pin added in #496.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants