Skip to content

feat: reverse phone lookup (provider-adapter, fail-closed) - #156

Merged
munisp merged 4 commits into
mainfrom
feat/phone-lookup
Sep 13, 2026
Merged

munisp merged 4 commits into
mainfrom
feat/phone-lookup

Conversation

@munisp

@munisp munisp commented Sep 13, 2026

Copy link
Copy Markdown
Owner

What

WP5 — reverse phone lookup (Intelius reverse-phone analog), provider-agnostic and fail-closed.

Go gateway (services/gateway/):

  • phone_lookup.go (new): PhoneProvider interface (Lookup(ctx, msisdn) (*PhoneRecord, error)); PhoneRecord{number, e164, carrier, lineType(mobile|landline|voip|unknown), subscriberName, country, source, checkedAt}.
  • Ordered provider chain from env PHONE_LOOKUP_PROVIDERS (comma-separated). Implemented provider: hlr — generic HLR-lookup HTTP API (HLR_API_URL, HLR_API_KEY, HLR_TIMEOUT_MS, default 8s); GET {base}/lookup?msisdn={e164} with Bearer + X-API-Key; strict JSON decoding (DisallowUnknownFields); per-provider consecutive-failure circuit breaker (no cockatiel equivalent exists in the Go service — threshold 3, 30s cooldown, single half-open probe).
  • Fail-closed: no provider configured (or provider configured without credentials) → 503 phone lookup not configured (PHONE_LOOKUP_NOT_CONFIGURED). No stub/deny providers, no synthetic records. All-providers-failed → 503 PHONE_PROVIDER_UNAVAILABLE.
  • Route GET /v1/phone/{number} registered in main.go newRouter with the same protected() middleware chain as /v1/nin (cors + logging + authMiddleware).
  • E.164 normalization, default region NG (no phone lib in go.mod — custom strict normalizer): accepts +234…, 234… (13 digits), 0-trunk national, bare 10-digit NSN, punctuation ()-. ; NG numbering plan enforced (mobile: 10-digit NSN starting 7/8/9; landline: 8–9 digit NSN starting 1/2). Invalid → 400. Non-NG numbers rejected unless PHONE_LOOKUP_ALLOW_INTERNATIONAL=true (then strict ITU E.164 shape).
  • Structured logging with masked MSISDN (+2348031234567+2348*******67); full numbers never logged; no secrets logged. Redis cache 6h, outbox event bis.gateway.phone_lookup (masked).

Node BFF (server/routers.ts): new phone procedure in lookupRouter mirroring cac exactly — see "NOT PUSHED" below.

Why

Closes the Intelius-style reverse-phone gap: given an MSISDN, return carrier/line-type/subscriber identity from authoritative HLR sources, never synthesized.

How tested (real output)

Go (cd services/gateway && go test -v -run 'Phone|NormalizePhone|MaskMSISDN' .):

--- PASS: TestNormalizePhoneNG_NigerianFormats (10 subtests: trunk-zero/bare-NSN/234-prefix/E164/spaces/dashes/parens/landline/whitespace)
--- PASS: TestNormalizePhoneNG_RejectsInvalid (9 malformed inputs)
--- PASS: TestNormalizePhoneNG_InternationalGating
--- PASS: TestMaskMSISDN
--- PASS: TestPhoneChainFailover_FirstProviderErrorsSecondAnswers  (httptest server A→502, server B→record)
--- PASS: TestPhoneChainFailover_MalformedJSONFallsThrough         (invalid JSON + unknown-field strict decode)
--- PASS: TestPhoneChainAllProvidersFail
--- PASS: TestPhoneChainCircuitBreakerOpens                        (3 failures → open, no 4th upstream call)
--- PASS: TestPhoneLookupFailClosed_NoProvidersConfigured          (503 "phone lookup not configured")
--- PASS: TestPhoneLookupFailClosed_ConfiguredButMissingCredentials (503)
--- PASS: TestPhoneLookupRejectsInvalidNumber                      (400s)
--- PASS: TestPhoneLookupSuccessPath
--- PASS: TestPhoneRouteRequiresAuth                               (401 no key / 401 wrong key / 503 authenticated empty chain)
--- PASS: TestBuildPhoneChainFromEnv
--- PASS: TestPhoneCircuitBreakerHalfOpenRecovery
PASS
ok  	bis/gateway	0.117s

Full suite: go test ./... → all packages ok (bis/gateway 0.106s, dapr, insider, kafka, opensearch, permify, redis, retry, verify). go vet . clean; gofmt -l clean for new/changed files.

Node (pnpm vitest run server/lookup-phone.test.ts):

 ✓ server/lookup-phone.test.ts (4 tests) 18ms
 Test Files  1 passed (1)
      Tests  4 passed (4)

Covers: proxy path /v1/phone/... + X-BIS-Key header, URL-encoding, zod rejection (BAD_REQUEST, gateway untouched), UNAUTHORIZED for anonymous. pnpm check (tsc --noEmit) clean with the routers.ts edit applied locally.

⚠️ NOT PUSHED — server/routers.ts (374KB, too large for MCP push; integration commit needed)

main.go (54KB) WAS pushed successfully — the route registration is on this branch (verified: branch-vs-main diff = exactly + mux.HandleFunc("/v1/phone/", protected(handlePhoneLookup)) after the /v1/cac/ line). The only missing piece is this block in server/routers.ts lookupRouter, immediately after the cac procedure (anchor below):

  cac: protectedProcedure
    .input(z.object({ rc: z.string() }))
    .query(async ({ input }) => gatewayFetch(`/v1/cac/${input.rc}`)),

  // ── INSERT AFTER THE cac PROCEDURE ──
  phone: protectedProcedure
    .input(z.object({ number: z.string().min(7).max(20).regex(/^\+?[0-9][0-9\s\-().]*$/) }))
    .query(async ({ input }) => gatewayFetch(`/v1/phone/${encodeURIComponent(input.number)}`)),

The companion test server/lookup-phone.test.ts IS on this branch and passes against the edited routers.ts.

Risks

  • hlr wire contract (GET {base}/lookup?msisdn=, fields msisdn/e164/carrier/line_type/subscriber_name/country) is generic; a concrete vendor may need a mapping tweak — strict decoding will fail closed (fall through / 503), never mis-decode silently.
  • phoneChain is built once at gateway startup; env changes require restart (consistent with other gateway providers).
  • No Permify check on /v1/phone/ (mirrors /v1/cac/; /v1/nin|bvn add Permify). Follow-up if policy requires read:phone.

Ops follow-up

Live vendor credentials: set PHONE_LOOKUP_PROVIDERS=hlr, HLR_API_URL, HLR_API_KEY (and optionally HLR_TIMEOUT_MS, PHONE_LOOKUP_ALLOW_INTERNATIONAL=true). Until then the endpoint fail-closes with 503.

@munisp

munisp commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

Verification defect fixed — MSISDN log leak via *url.Error (commit 9701f62).

Fix (hlrProvider.Lookup): transport errors from client.Do are *url.Error values embedding the request URL (with the full msisdn query param). Now stripped before wrapping:

resp, err := p.client.Do(req)
if err != nil {
	// *url.Error embeds the request URL, which carries the full MSISDN
	// query parameter — strip it so chained/logged errors never leak the
	// phone number.
	var urlErr *url.Error
	if errors.As(err, &urlErr) {
		return nil, fmt.Errorf("hlr http call: %w", urlErr.Err)
	}
	return nil, fmt.Errorf("hlr http call: %w", err)
}

Audited the other error paths in the provider: http.NewRequestWithContext errors don't include the URL, status/decode errors carry only codes/decoder messages — client.Do was the only leak site.

Regression tests added (both pass):

  • TestPhoneChainErrorDoesNotLeakMSISDN — unroutable endpoint forces the *url.Error path; asserts the chained error string contains no MSISDN (raw, digits-only, %2B…-escaped, or msisdn=).
  • TestPhoneLookupFailureLogDoesNotLeakMSISDN — end-to-end through the handler with captured log output; asserts logs contain only the masked form (+2348*******67) and no raw/escaped number.

Re-run results:

go test -v -run 'Phone|NormalizePhone|MaskMSISDN' .  → 17/17 PASS, ok bis/gateway 0.086s
go test -count=1 ./...                                → all packages ok
go vet .                                              → clean
gofmt -l phone_lookup.go phone_lookup_test.go         → clean (no output)

Pushed files verified byte-identical to the locally tested copies (post-push branch tarball diff).

@munisp
munisp merged commit db9befc into main Sep 13, 2026
8 of 10 checks passed
munisp added a commit that referenced this pull request Sep 14, 2026
…gration) (#160)

- WP1 (#154): entitySearchRouter import + appRouter registration
- WP2 (#158): monitoringRouter import + appRouter registration
- WP3 (#155): subjectPortalRouter + computeDataCompleteness imports; subjectPortal registration; removed routers.ts-local getFallbackSuggestion (now shared in server/dataCompleteness.ts); getDataCompleteness delegates to computeDataCompleteness; consentPurposeEnum gains consumer_self_check; subjectAccessTokens/subjectDisputes pgTable declarations (matches drizzle/0023_subject_portal.sql)
- WP4 (#157): shareableReportsRouter + selfServiceBillingRouter imports + registrations; reportShareLinks/planSignups pgTable declarations (matches drizzle/0024_share_links_and_plan_signups.sql)
- WP5 (#156): lookup.phone procedure (gatewayFetch /v1/phone/:number, validated input)

Co-authored-by: bis-integration <integration@bis.local>
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.

1 participant