Skip to content

Decision: Public, Cached FSP Hostname Resolve Endpoint

The PWA is served from *.app.atram.ai and other FSP-specific hostnames, but before this change every hostname rendered the same generic Atram branding — a partner bank's customers arriving from a Meta ad saw Atram's logo and colors, not their own bank's [@solution-doc]. Fixing that meant answering one question the backend had never been asked before: "which FSP owns the hostname this request came in on," and answering it before the user has authenticated, since branding has to be visible on the PWA's very first paint.

Context

FSP branding — logo, colors, terms-of-service and privacy links — lives on the Fsp model, keyed off fsps.frontend_url [@decision-doc]. Nothing exposed that mapping to an unauthenticated client. Two design questions had to be settled: how to match a hostname to a frontend_url value, and how to keep that lookup cheap and public without leaking anything beyond branding data.

The original implementation plan proposed a SQL LIKE/REPLACE match directly against the stored frontend_url string. This was rejected: stored frontend_url values are inconsistently shaped — some have trailing slashes, some carry paths, some historically omit a scheme — so a single REPLACE chain would silently return no row on whichever variant it didn't anticipate, and database collation adds its own case-sensitivity mismatch on top [@solution-doc]. With FSP count in the single digits, there was nothing to gain performance-wise from pushing the match into SQL that offset that fragility [@decision-doc].

A second problem rode along: FspResource.privacy_link was hardcoded to 'https://google.com' as a placeholder. That was tolerable while the resource was only reachable by authenticated callers, but shipping a public resolve endpoint would have surfaced that fake legal link to every anonymous PWA boot, so it had to be fixed in the same change [@decision-doc].

Decision

GET /api/fsp/resolve?hostname=<host> is public — no auth guard — mounted under the pwa.locale middleware group and throttled to 60 requests per minute per IP, generous for a client that calls it once on boot while still bounding abuse of an unauthenticated route [@decision-doc]. FspResolveController::lookupFspId() fetches every FSP with a non-null frontend_url, runs parse_url($fsp->frontend_url, PHP_URL_HOST) on each after normalizing both the URL (prefixing // when no scheme is present) and the candidate host to lowercase and trimmed, and compares that against the equally-normalized query hostname — exact host equality, not a substring match, chosen specifically because it is robust to the trailing-slash, path, and scheme variance that made LIKE fragile [@fsp-resolve-controller] [@decision-doc]. An unknown, missing, or empty hostname returns a plain abort(404), deliberately without a richer error envelope, since the PWA's only useful response to either case is falling back to default branding [@decision-doc].

Results are cached, but the cache-invalidation mechanism in the shipped code differs from what both source documents describe. The design docs describe a fsp:resolve:{hostname} key with every written key appended to a tracked fsp:resolve:keys index, which FspObserver would iterate and flush on any FSP save or delete [@decision-doc] [@solution-doc]. The code that actually shipped takes a simpler route: FspResolveController::cacheKey() reads a version number from a single fsp:resolve:version cache key and folds it into the per-hostname key as fsp:resolve:v{version}:{hostname}, and FspObserver invalidates everything in O(1) by atomically incrementing that version key (seeding it to 2 if increment fails, e.g. on a cache store without atomic increment support) rather than tracking and flushing an explicit list of keys [@fsp-resolve-controller] [@fsp-observer]. Both approaches solve the same underlying problem — a hostname can move from one FSP to another over time, so invalidating only the saved FSP's current hostname would leave the prior owner's cached entry stale — but the version-bump strategy avoids maintaining a growing index of tracked keys entirely; old per-host keys under the previous version simply age out on their own TTL instead of being explicitly forgotten. The cache still holds only the FSP id, not the rendered FspResource, and the controller re-fetches the Fsp model and returns a fresh abort(404) (busting the stale key) if a cached id no longer resolves to a real row [@fsp-resolve-controller] [@decision-doc].

FspResource.privacy_link was fixed in the same change: a new nullable privacy_link column was added to fsps, added to Fsp::$fillable, and FspResource now returns the real (possibly null) value instead of the hardcoded placeholder [@decision-doc].

Status

Shipped (PR #216) and documented from both the decision and incident-retrospective angle in the two source docs this page draws from [@decision-doc] [@solution-doc]. The controller and observer inspected for this page match the version-bump cache strategy, which is the current behavior regardless of which invalidation approach the earlier design prose describes.

Consequences

The PWA can now brand itself correctly on first load for any FSP whose frontend_url is set, without waiting on authentication, and frontend_url itself is deliberately never returned in the response — it is treated as internal wiring, not part of the public branding contract [@decision-doc]. The parse_url()-based matching approach is the pattern the same solution doc flags as reusable for other multi-tenant hostname-to-FSP concerns, including the Telegram mini-app integration [@solution-doc].

The design accepted a real limitation rather than solving it: there is no normalization or backfill of existing frontend_url data, so any FSP whose stored URL cannot be parsed into a clean host by parse_url() will simply fail to resolve and the PWA falls back to default branding with no error surfaced to an operator [@decision-doc]. Flushing the entire resolve cache on every FSP write, rather than targeting the affected hostname, is deliberately imprecise — accepted because FSP writes are rare and the cost of a full cache refill is negligible compared to the risk of a stale hostname-to-FSP mapping surviving a rename [@solution-doc]. See FSP as a Multi-Tenancy Concept for how frontend_url and branding fields fit into the rest of the Fsp model, and HTTP Route Surfaces Map for where this public endpoint sits relative to the rest of routes/api.php.