Skip to content

Commit 0e2743f

Browse files
committed
Document Scamalytics failure fallback
1 parent a53037d commit 0e2743f

4 files changed

Lines changed: 94 additions & 2 deletions

File tree

docs/environment-variables.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- Runtime/OS env: pass typed snapshots instead of reading `process.env` throughout the codebase.
88
- `IPINFO_TOKEN` is required; free-mode country gating uses it to check IPinfo privacy signals for VPN/proxy/Tor/relay/hosting traffic.
99
- `SPUR_TOKEN` is required; VPN/proxy/Tor/residential-proxy privacy signals use Spur Context API corroboration.
10-
- `SCAMALYTICS_API_KEY` is required; when IPinfo reports privacy or hosting/service signals, free-mode gating also checks Scamalytics for a fraud score and proxy/Tor/VPN evidence. In allowlisted countries, full access requires both Spur and Scamalytics to return clean follow-up results. Provider failures, ambiguous results, VPN/generic-proxy signals, and hosting/datacenter signals fall back to limited access. Residential proxy is blocked only when Scamalytics also reports residential/proxy evidence or a medium+ fraud score, as are Cloudflare Tor or Tor corroborated by another provider.
10+
- `SCAMALYTICS_API_KEY` is required; when IPinfo reports privacy or hosting/service signals, free-mode gating also checks Scamalytics for a fraud score and proxy/Tor/VPN evidence. In allowlisted countries, full access requires both Spur and Scamalytics to return clean follow-up results. Provider failures, Scamalytics outages/API errors, ambiguous results, VPN/generic-proxy signals, and hosting/datacenter signals fall back to limited access. Residential proxy is blocked only when Scamalytics also reports residential/proxy evidence or a medium+ fraud score, as are Cloudflare Tor or Tor corroborated by another provider.
1111
- `CODEBUFF_FULL_TELEMETRY=true` or `CODEBUFF_FULL_TELEMETRY_IDS=user-id,email@example.com`
1212
disables client analytics sampling for targeted debugging. Use sparingly because it can send full CLI log payloads.
1313

docs/freebuff-waiting-room.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ All endpoints authenticate via the standard `Authorization: Bearer <api-key>` or
181181
- Existing active+unexpired row, **different model** → reject with `model_locked` (HTTP 409); `active_instance_id` is **not** rotated so the other CLI stays valid. Client must DELETE the session before switching.
182182
- Existing active+expired row → reset to queued with fresh `queued_at` and the requested `model` (re-queue at back).
183183

184-
Before any of those state transitions, the handler requires a resolved country and IPinfo privacy classification. Unsupported countries enter limited Freebuff access. In allowlisted countries, IPinfo privacy/hosting/service signals trigger paid follow-up checks with Spur and Scamalytics. Full access is restored when both follow-up providers return clean context; suspicious or failed follow-up checks fall back to limited access. The server records a 0-100 privacy risk score for observability/cache rows; named/recent IPinfo anonymizer observations raise that score, while generic Scamalytics third-party proxy labels do not override a low top-level Scamalytics score by themselves. VPN, generic proxy, and hosting/datacenter signals limit access when follow-up providers do not clear them. Residential proxy signals hard-block only when Scamalytics also reports residential/proxy evidence or a medium+ fraud score. Cloudflare Tor country detection or Tor corroborated by another provider is also hard-blocked by the IP-intelligence gate.
184+
Before any of those state transitions, the handler requires a resolved country and IPinfo privacy classification. Unsupported countries enter limited Freebuff access. In allowlisted countries, IPinfo privacy/hosting/service signals trigger paid follow-up checks with Spur and Scamalytics. Full access is restored when both follow-up providers return clean context; suspicious or failed follow-up checks fall back to limited access. A Scamalytics outage/API error is treated as a transient limited decision, not a hard block. The server records a 0-100 privacy risk score for observability/cache rows; named/recent IPinfo anonymizer observations raise that score, while generic Scamalytics third-party proxy labels do not override a low top-level Scamalytics score by themselves. VPN, generic proxy, and hosting/datacenter signals limit access when follow-up providers do not clear them. Residential proxy signals hard-block only when Scamalytics also reports residential/proxy evidence or a medium+ fraud score. Cloudflare Tor country detection or Tor corroborated by another provider is also hard-blocked by the IP-intelligence gate.
185185

186186
Response shapes:
187187

web/src/server/__tests__/free-mode-country-access-cache.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,42 @@ describe('free mode country access cache', () => {
195195
).toBe(FREE_MODE_COUNTRY_CACHE_TRANSIENT_BLOCK_TTL_MS)
196196
})
197197

198+
test('stores transient limited decisions when Scamalytics fails after hard IPinfo signals', async () => {
199+
const cacheStore: FreeModeCountryAccessCacheStore = {
200+
get: mock(async () => null),
201+
set: mock(async () => {}),
202+
}
203+
204+
const access = await getCachedFreeModeCountryAccess({
205+
userId,
206+
req: makeReq({
207+
'cf-ipcountry': 'US',
208+
'cf-connecting-ip': clientIp,
209+
}),
210+
options: {
211+
ipinfoToken: 'test-token',
212+
spurToken: 'test-spur-token',
213+
ipHashSecret,
214+
lookupIpPrivacy: async () => ({ signals: ['vpn'] }),
215+
lookupSpurIpPrivacy: async () => ({ signals: ['vpn'] }),
216+
lookupScamalyticsIpRisk: async () => null,
217+
},
218+
cacheStore,
219+
now,
220+
})
221+
222+
expect(access.allowed).toBe(false)
223+
expect(access.scamalyticsStatus).toBe('failed')
224+
expect(cacheStore.set).toHaveBeenCalledWith({
225+
userId,
226+
access,
227+
now,
228+
})
229+
expect(
230+
expiresAtForCountryAccess(access, now).getTime() - now.getTime(),
231+
).toBe(FREE_MODE_COUNTRY_CACHE_TRANSIENT_BLOCK_TTL_MS)
232+
})
233+
198234
test('stores allowed decisions when clean Spur context clears a hard IPinfo signal', async () => {
199235
const cacheStore: FreeModeCountryAccessCacheStore = {
200236
get: mock(async () => null),

web/src/server/__tests__/free-mode-country.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,62 @@ describe('free mode country access', () => {
443443
expect(shouldHardBlockFreeModeAccess(access)).toBe(false)
444444
})
445445

446+
test('keeps Scamalytics outages limited instead of hard-blocked', async () => {
447+
const access = await getFreeModeCountryAccess(
448+
makeReq({
449+
'cf-ipcountry': 'US',
450+
'x-forwarded-for': '203.0.113.10',
451+
}),
452+
{
453+
ipinfoToken: 'test-token',
454+
spurToken: 'test-spur-token',
455+
lookupIpPrivacy: async () => ({
456+
signals: ['res_proxy'],
457+
}),
458+
lookupSpurIpPrivacy: async () => ({
459+
signals: ['proxy'],
460+
}),
461+
lookupScamalyticsIpRisk: async () => {
462+
throw new Error('Scamalytics unavailable')
463+
},
464+
},
465+
)
466+
467+
expect(access.allowed).toBe(false)
468+
expect(access.blockReason).toBe('anonymous_network')
469+
expect(access.scamalyticsStatus).toBe('failed')
470+
expect(getFreeModePrivacyDecision(access)).toBe(
471+
'scamalytics_failed_limited',
472+
)
473+
expect(shouldHardBlockFreeModeAccess(access)).toBe(false)
474+
})
475+
476+
test('treats Scamalytics API errors as limited, not blocked', async () => {
477+
const access = await getFreeModeCountryAccess(
478+
makeReq({
479+
'cf-ipcountry': 'US',
480+
'x-forwarded-for': '203.0.113.10',
481+
}),
482+
{
483+
ipinfoToken: 'test-token',
484+
spurToken: 'test-spur-token',
485+
lookupIpPrivacy: async () => ({
486+
signals: ['vpn'],
487+
}),
488+
lookupSpurIpPrivacy: async () => ({
489+
signals: ['vpn'],
490+
}),
491+
lookupScamalyticsIpRisk: async () => null,
492+
},
493+
)
494+
495+
expect(access.allowed).toBe(false)
496+
expect(access.blockReason).toBe('anonymous_network')
497+
expect(access.scamalyticsStatus).toBe('failed')
498+
expect(getFreeModeRiskScore(access)).toBe(75)
499+
expect(shouldHardBlockFreeModeAccess(access)).toBe(false)
500+
})
501+
446502
test('keeps IPinfo VPN/proxy detections in limited mode when Spur lookup fails', async () => {
447503
const access = await getFreeModeCountryAccess(
448504
makeReq({

0 commit comments

Comments
 (0)