One backend, and only the screens it can stand behind
The console defaulted to a backend on localhost, and features were built against a locally modified server that production never had: Floor, Commerce and their sales/customers routes answered 404 the day they were deployed. The platform API is now https://mcp.loyaly.ai in every environment; LOYALY_API_BASE remains only as an explicit override. Removed what had no server behind it - Floor, Commerce, Lyts, Leaderboard, and the Roles, Notifications, Billing, Integrations, API keys and Preferences settings pages, all of which rendered hard-coded arrays as if they were the merchant's data. Navigation is what the API can honestly back. The removed code is in history if a real backend for any of it is ever built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
@@ -1,184 +1,63 @@
|
||||
/**
|
||||
* Where the Loyaly platform API lives, and the rules about what may be called
|
||||
* one. Pure configuration resolution: no I/O, no crypto, no `server-only`.
|
||||
* Where the Loyaly platform API lives.
|
||||
*
|
||||
* ── Why it is not in apiClient ───────────────────────────────────────────
|
||||
* apiClient is `server-only`, and that package resolves to a module which
|
||||
* THROWS ON IMPORT outside a react-server condition. src/instrumentation.ts
|
||||
* runs this same validation at boot and is NOT compiled in that condition, so
|
||||
* importing apiClient from it would crash the server on start — for every
|
||||
* deployment, correctly configured or not.
|
||||
* There is ONE backend, `https://mcp.loyaly.ai`, and this console talks to it
|
||||
* in every environment - development included. The console used to default to
|
||||
* a backend on localhost, and the result was features built against a locally
|
||||
* modified server that production never had: whole screens answered 404 the
|
||||
* day they were deployed. A developer who genuinely needs another backend sets
|
||||
* LOYALY_API_BASE explicitly; nothing defaults to it.
|
||||
*
|
||||
* Splitting it also keeps one source of truth: the boot check and the request
|
||||
* path call the SAME function against the SAME allowlist, so a check that
|
||||
* passes at startup cannot be contradicted by the first request.
|
||||
* Server-side only and deliberately not NEXT_PUBLIC: the browser talks to this
|
||||
* app's own routes, never to the platform, which is what keeps the access
|
||||
* token out of JavaScript.
|
||||
*
|
||||
* Resolved lazily, not at module scope: `next build` imports every route
|
||||
* module, and a value read at import time turns a missing runtime variable
|
||||
* into a build failure.
|
||||
*/
|
||||
|
||||
import {ConfigError} from '@/shared/errors/configError';
|
||||
|
||||
/**
|
||||
* Server-side only — deliberately NOT NEXT_PUBLIC. Publishing the platform
|
||||
* host would let a browser bypass the BFF, which is the whole point of it.
|
||||
*
|
||||
* ── Why there is no remote fallback ──────────────────────────────────────
|
||||
* This used to default to `https://platform.loyaly.ai`, which is NOT the
|
||||
* Behavision API — that host serves this very console. Measured: it answers
|
||||
* `GET /api/auth/me` with the console's own 404 HTML page, and a login POST
|
||||
* with the console's own `{error:{code,message}}` envelope rather than the
|
||||
* platform's flat `{error,message}`. So an unset variable did not fail; it
|
||||
* quietly pointed the BFF at its own origin, and every upstream call became a
|
||||
* request the console made to itself.
|
||||
*
|
||||
* A wrong host that *works* is worse than a startup failure, so production
|
||||
* refuses to SERVE without the variable — the same stance `tokenStore.ts`
|
||||
* takes on AUTH_SECRET, and for the same reason. Development falls back to the
|
||||
* local backend, which is the only host a dev machine can usefully mean.
|
||||
*
|
||||
* local http://127.0.0.1:8088
|
||||
* production https://mcp.loyaly.ai
|
||||
*
|
||||
* ── Why this is resolved lazily and not at module scope ──────────────────
|
||||
* It used to be `const BASE = resolveBase()`, evaluated the moment any module
|
||||
* imported this one. That broke `next build`: the "Collecting page data" step
|
||||
* imports every route module, the Docker builder stage sets NODE_ENV=production,
|
||||
* and LOYALY_API_BASE is a RUNTIME value that is not present while building an
|
||||
* image. So the guard fired against the build instead of against a
|
||||
* misconfigured server, and the deploy failed with "Failed to collect page data
|
||||
* for /api/assistant".
|
||||
*
|
||||
* Deferring to first use draws the line where it belongs: building an image
|
||||
* needs no platform host, serving a request does. `tokenStore.key()` is a
|
||||
* function for exactly this reason — this now matches it rather than only
|
||||
* claiming to. The result is memoised, so the environment is read once per
|
||||
* process and a healthy server pays nothing per request.
|
||||
*/
|
||||
const DEV_API_BASE = 'http://127.0.0.1:8088';
|
||||
export const PRODUCTION_API_ORIGIN = 'https://mcp.loyaly.ai';
|
||||
|
||||
/**
|
||||
* The only origin that serves the Loyaly platform API in production.
|
||||
*
|
||||
* Production is an allowlist of exactly one entry rather than a shape check,
|
||||
* because "looks like a URL" is what let the wrong host through before. A new
|
||||
* environment — staging, a regional deployment — is a deliberate line added
|
||||
* here, not something a typo in a dashboard can invent.
|
||||
*/
|
||||
const PRODUCTION_API_ORIGIN = 'https://mcp.loyaly.ai';
|
||||
|
||||
/**
|
||||
* Hosts that are definitely NOT the API, and why.
|
||||
*
|
||||
* Rejected in EVERY environment, development included: this is not a
|
||||
* production-hardening rule, it is a statement of fact about what the host
|
||||
* serves. Naming the reason matters — "rejected" alone sends somebody looking
|
||||
* for a firewall or a DNS problem, when the actual fix is one word in a
|
||||
* variable.
|
||||
* Hosts that are definitely not the API. `platform.loyaly.ai` serves this very
|
||||
* console; pointing the BFF there makes it call its own origin, which fails in
|
||||
* a way that looks like a broken login form rather than a wrong variable.
|
||||
*/
|
||||
const KNOWN_WRONG_HOSTS: Record<string, string> = {
|
||||
'platform.loyaly.ai':
|
||||
'serves this console, not the Loyaly API — pointing the BFF there makes ' +
|
||||
'it call its own origin',
|
||||
'platform.loyaly.ai': 'serves this console, not the Loyaly API',
|
||||
};
|
||||
|
||||
/**
|
||||
* `buildUrl` — which resolves this variable — is called OUTSIDE the try block
|
||||
* that turns a failed fetch into `UpstreamError(0, 'network')`. It used to be
|
||||
* inside it, which made "nobody set LOYALY_API_BASE" indistinguishable from
|
||||
* "the platform is down" at every call site, and had the login route report
|
||||
* both as 502 platform_unreachable.
|
||||
*/
|
||||
function configError(detail: string): ConfigError {
|
||||
return new ConfigError(
|
||||
`LOYALY_API_BASE is invalid: ${detail}. ` +
|
||||
`Set it to ${PRODUCTION_API_ORIGIN} in production, or ${DEV_API_BASE} locally.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a configured value and reduce it to an origin.
|
||||
*
|
||||
* The path is dropped on purpose rather than preserved: `new URL(path, base)`
|
||||
* has always discarded a base path, so a value like `https://host/v1` never
|
||||
* did what whoever wrote it expected. Returning the origin makes that visible
|
||||
* instead of silently ignored.
|
||||
*/
|
||||
function validateBase(raw: string, isProduction: boolean): string {
|
||||
function validateBase(raw: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw configError(`"${raw}" is not an absolute URL`);
|
||||
throw new ConfigError(`LOYALY_API_BASE "${raw}" is not an absolute URL`);
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw configError(`"${url.protocol}" is not an http(s) URL`);
|
||||
throw new ConfigError(`LOYALY_API_BASE "${raw}" is not an http(s) URL`);
|
||||
}
|
||||
|
||||
const wrong = KNOWN_WRONG_HOSTS[url.hostname];
|
||||
if (wrong) throw configError(`${url.hostname} ${wrong}`);
|
||||
|
||||
if (isProduction) {
|
||||
if (url.origin !== PRODUCTION_API_ORIGIN) {
|
||||
throw configError(
|
||||
`${url.origin} is not a supported production API host`,
|
||||
);
|
||||
}
|
||||
return url.origin;
|
||||
if (wrong) throw new ConfigError(`LOYALY_API_BASE ${url.hostname} ${wrong}`);
|
||||
if (process.env.NODE_ENV === 'production' && url.origin !== PRODUCTION_API_ORIGIN) {
|
||||
throw new ConfigError(
|
||||
`LOYALY_API_BASE ${url.origin} is not the production API (${PRODUCTION_API_ORIGIN})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Development stays permissive by design. A dev legitimately points this at
|
||||
* a LAN address, a tunnel or a container host, and breaking that to enforce
|
||||
* a production rule would cost more than it protects — nothing a dev machine
|
||||
* reaches is production. The known-wrong list above still applies.
|
||||
*/
|
||||
// The path is dropped: `new URL(path, base)` always discarded it, so a
|
||||
// value like https://host/v1 never did what its author expected.
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
let cachedBase: string | null = null;
|
||||
|
||||
function resolveBase(): string {
|
||||
/** The upstream origin, resolved on first use and memoised. */
|
||||
export function resolvePlatformOrigin(): string {
|
||||
if (cachedBase !== null) return cachedBase;
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const configured = process.env.LOYALY_API_BASE?.trim();
|
||||
|
||||
if (configured) {
|
||||
// NOT cached before validating: an invalid value must throw on every
|
||||
// request, the same way a missing one does.
|
||||
return (cachedBase = validateBase(configured, isProduction));
|
||||
}
|
||||
|
||||
/**
|
||||
* ── Why an unset variable is no longer fatal in production ───────────────
|
||||
* Production accepts exactly ONE origin (the allowlist below), so an unset
|
||||
* LOYALY_API_BASE could never have meant anything other than that origin.
|
||||
* Requiring an operator to type the single permitted value added a failure
|
||||
* mode without adding a choice — and it is a failure mode that fires easily:
|
||||
* `.env` ships this value inside the image, but @next/env only fills a
|
||||
* variable that is ABSENT. Measured against the installed @next/env: a real
|
||||
* environment variable set to the EMPTY STRING is left empty, and the file is
|
||||
* not consulted. So one blank field in a dashboard defeated the shipped
|
||||
* default and took production down with "required in production".
|
||||
*
|
||||
* This is not the remote fallback that 759f3b7 removed. That one defaulted to
|
||||
* `https://platform.loyaly.ai` — the console's OWN origin, a host that is not
|
||||
* the API at all and that answers wrongly instead of failing. This defaults to
|
||||
* the one host the validator already insists on, and every other value,
|
||||
* including that old wrong one, is still rejected by name below.
|
||||
*
|
||||
* The result is that production has exactly one required variable —
|
||||
* AUTH_SECRET — which is the only value that genuinely cannot be shipped.
|
||||
*/
|
||||
if (isProduction) return (cachedBase = PRODUCTION_API_ORIGIN);
|
||||
|
||||
return (cachedBase = DEV_API_BASE);
|
||||
// Not cached before validating: an invalid value must fail on every request.
|
||||
return (cachedBase = configured ? validateBase(configured) : PRODUCTION_API_ORIGIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* The upstream origin, resolved on first use and memoised. Call it; do not
|
||||
* hoist it — see the note on lazy resolution above.
|
||||
*/
|
||||
export {resolveBase as resolvePlatformOrigin};
|
||||
|
||||
/** The one production origin, for messages that need to name it. */
|
||||
export {PRODUCTION_API_ORIGIN};
|
||||
|
||||
@@ -14,11 +14,7 @@ export interface NavEntry {
|
||||
*/
|
||||
export const PRIMARY_NAV: NavEntry[] = [
|
||||
{label: 'Dashboard', href: '/dashboard', icon: ICONS.dashboard},
|
||||
{label: 'Floor', href: '/floor', icon: ICONS.visitors},
|
||||
{label: 'Commerce', href: '/commerce', icon: ICONS.commerce},
|
||||
{label: 'Store', href: '/stores', icon: ICONS.stores},
|
||||
{label: 'Lyts', href: '/lyts', icon: ICONS.lyts},
|
||||
{label: 'Leaderboard', href: '/staff', icon: ICONS.leaderboard},
|
||||
{label: 'Stores', href: '/stores', icon: ICONS.stores},
|
||||
];
|
||||
|
||||
/** Pinned to the bottom of the sidebar via SideNav's `footer` slot. */
|
||||
|
||||
Reference in New Issue
Block a user