@erpgulf/server-lookup
v1.0.0
Published
Framework-independent company-code backend server lookup for ERPGulf applications
Readme
@erpgulf/server-lookup
Resolves a customer's backend server URL from a company code. Each ERPGulf
customer (a building or a company) runs its own Frappe/ERPNext instance, so an
app must learn which instance to talk to before login or onboarding can start.
During setup the user types a company code; this package asks the public lookup
service about it, decodes the answer on the device, and returns a normalized
backend URL such as https://building.example.test:3045.
The package has no React, React Native, Expo, i18n, or logging dependency, never
reads process.env, and depends only on zod v4. It also exports the URL
helpers that the manual "server address" fallback shares with the lookup.
The token protocol is obfuscation, not encryption. Its key ships inside every app bundle and is not secret. See How it works.
License
This package is licensed under the MIT License, copyright 2026 ERPGulf.
Installation
The package is published publicly on npmjs.org and needs no registry credentials:
npm install @erpgulf/server-lookupOr with pnpm:
pnpm add @erpgulf/server-lookupIt ships ESM only. A single module format means a single copy of each error
class, so instanceof ServerLookupError checks are reliable.
How it works
The app sends one anonymous request, with no auth, cookies, customer session, or retries, and a 15-second timeout:
GET {lookupUrl}/api/method/get_mobile_server_url?company_code=<url-encoded code>
Accept: application/jsonThe service answers with a Frappe envelope:
{ "message": { "success": true, "resp": "<token>" } } when the code is found,
or { "message": { "success": false, … } } when it isn't. Any error text in a
not-found answer is discarded and never shown to users.
The token format is fixed by the backend, and other apps depend on it bit for
bit. A token is <nonce>.<body> with exactly one . and at most 16,384
characters. The nonce is non-empty with no whitespace, and the body is
non-empty.
- Body encoding. The body is a base64-shaped six-bit encoding over the
configured 64-character alphabet, padded with
~instead of=. Its length is a multiple of 4, every symbol is in the alphabet, and the encoding is canonical: the unused bits in a padded final quartet are zero. - Reversal. The decoded byte array is reversed.
- Keystream. The bytes are XORed with RC4 keyed by the UTF-8 bytes of
secret + nonce, with the first 256 keystream bytes discarded (RC4-drop256). - Checksum. The result is
plaintext || checksum: the last 4 bytes are the big-endian 32-bit FNV-1a hash of the plaintext. A mismatch is rejected, and so is empty plaintext. - Text. The plaintext is decoded as UTF-8 with
fatal: true. Invalid UTF-8 is rejected, never repaired.
The decoded URL is trimmed, normalized, and validated like a
manually typed address, then returned as
{ backendUrl }.
This is obfuscation, not authenticated encryption. The "secret" ships inside every app bundle and is not confidential: anyone who has it can decode tokens or forge new ones. The checksum only detects a wrong key or a corrupted token. Trust in the result comes from HTTPS to the lookup service, not from the token.
Runtime requirements
The package needs these globals and ships no polyfills: fetch,
AbortController, setTimeout, TextEncoder, and TextDecoder (UTF-8, with
fatal: true support). Expo SDK 55 installs TextDecoder, and Hermes supplies
TextEncoder. Modern browsers and Node.js 18 or newer provide all five.
Usage in an Expo app
import {
lookupServer,
normalizeCompanyCode,
validateCompanyCode,
ServerLookupError,
ServerLookupConfigurationError,
} from "@erpgulf/server-lookup";
const code = normalizeCompanyCode(input);
const issue = validateCompanyCode(code); // "required" | "tooLong" | null
if (issue) {
// Show the field error; don't call lookupServer.
}
const { backendUrl } = await lookupServer(code, {
config: {
url: process.env.EXPO_PUBLIC_SERVER_LOOKUP_URL,
secret: process.env.EXPO_PUBLIC_SERVER_LOOKUP_SECRET,
alphabet: process.env.EXPO_PUBLIC_SERVER_LOOKUP_ALPHABET,
},
onDebug: __DEV__
? (event, data) => console.log(`[lookup] ${event}`, data)
: undefined,
});- Keep the
process.env.EXPO_PUBLIC_*reads in app code as literal property accesses. Expo inlines only those, which is why the package takes the values as arguments instead of reading them itself. lookupServersends the code exactly as given, so normalize and validate it first.- The config is validated on every call, before any request or timer. Missing or broken lookup config fails only when a lookup is attempted, never at import.
- Each call makes a single attempt with a 15-second timeout. There are no retries and no caching.
| Config field | Rule |
| ------------ | ----------------------------------------------------------------------------------------- |
| url | https: with no credentials, query, or fragment. Trailing slashes are removed. |
| secret | Not blank. Surrounding whitespace is kept, because it is key material. |
| alphabet | Exactly 64 unique printable ASCII characters from ! to }, with no ., ~, or space. |
Manual server address
The fallback where the user types the server address instead of a company code skips the lookup and its config entirely. It uses the same normalization and validation as a lookup result:
import {
normalizeBackendUrl,
validateBackendUrl,
} from "@erpgulf/server-lookup";
const backendUrl = normalizeBackendUrl(input);
const issue = validateBackendUrl(backendUrl); // "required" | "invalid" | nullnormalizeBackendUrl trims the input and assumes https:// when no scheme is
typed; a typed scheme is kept as typed. It also cuts a pasted /api/... path
and any trailing slashes. validateBackendUrl still accepts http:// for
existing LAN and development deployments. It catches typos, not policy: it
rejects other schemes and a host with neither a dot nor a port, except
localhost. Lookup results are additionally rejected if they contain
credentials, a query, a fragment, whitespace, or control characters.
Errors
| Thrown | When | Meaning for the user |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| ServerLookupConfigurationError | The config is missing or invalid. No request is sent. | Setup isn't configured in this app; contact support. |
| ServerLookupError with kind: "notFound" | HTTP 200 with message.success === false | The company code was not found. |
| ServerLookupError with kind: "unavailable" | A network error, a timeout or abort (including while reading the body), or any non-2xx status (body not trusted) | The setup service can't be reached; check the connection. |
| ServerLookupError with kind: "invalidResponse" | A non-JSON body, an envelope mismatch, a token decode or checksum failure, or a rejected decoded URL | The setup service returned an invalid response. |
Messages are fixed and never contain response data, the secret, or the alphabet:
Server lookup failed: <kind> and
Invalid server lookup configuration: <fields>. The fields property of
ServerLookupConfigurationError names the invalid config fields, never their
values. Branch on the error type and kind, and show your own copy:
try {
const { backendUrl } = await lookupServer(code, options);
// ...
} catch (error: unknown) {
if (error instanceof ServerLookupConfigurationError) {
showError(t("setup.notConfigured"));
} else if (error instanceof ServerLookupError) {
showError(t(`setup.${error.kind}`)); // notFound | unavailable | invalidResponse
} else {
throw error;
}
}Suggested copy
The package contains no translations; each app owns its own. Suggested copy:
| Case | English | Arabic |
| -------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| company code required | Enter your company code. | أدخل رمز الشركة. |
| company code tooLong | That company code is too long. Check it and try again. | رمز الشركة طويل جدًا. تحقّق منه وحاول مرة أخرى. |
| notFound | We couldn't find that company code. Check the code and try again. | لم نتمكّن من العثور على رمز الشركة. تحقّق من الرمز وحاول مرة أخرى. |
| unavailable | We couldn't connect to the setup service. Check your internet connection and try again. | تعذّر الاتصال بخدمة الإعداد. تحقّق من اتصالك بالإنترنت وحاول مرة أخرى. |
| invalidResponse | The setup service returned an invalid response. Please try again or contact support. | أعادت خدمة الإعداد استجابة غير صالحة. حاول مرة أخرى أو تواصل مع الدعم. |
| ServerLookupConfigurationError | Setup is not configured correctly in this app. Please contact support. | خدمة الإعداد غير مهيّأة بشكل صحيح في هذا التطبيق. يُرجى التواصل مع الدعم. |
| backend URL required | Enter the server address. | أدخل عنوان الخادم. |
| backend URL invalid | That address doesn't look right. Check it and try again. | لا يبدو هذا العنوان صحيحًا. تحقّق منه وحاول مرة أخرى. |
Debug events
onDebug is for development tracing. Every call to it is wrapped, so a throwing
hook can't change the lookup's result. It never receives the secret, the
alphabet, the raw response, or the token. The request event does include the
company code, so enable the hook only in development.
| Event | Data |
| ----------------- | ----------------------------------------------------------------------------- |
| request | { lookupUrl, companyCode } |
| requestFailed | { aborted, reason }, where reason is "<name>: <message>" or "unknown" |
| httpError | { status } |
| invalidJson | { aborted, reason } |
| schemaMismatch | { issues }, as "path: message" strings (the path defaults to root) |
| notFound | none |
| decodeFailed | { reason } |
| addressRejected | { issue } or { reason: "notPlainOrigin" } |
| resolved | { backendUrl } |
Integration guidance
- Persist the resolved
backendUrlonly after the backend itself answers successfully, for example at onboarding start. Never persist the company code. - Validate lazily, as this package does. Apps that are already set up must not break when the lookup config is absent or the lookup service is down.
- The manual "server address" path uses
normalizeBackendUrlandvalidateBackendUrland skips the lookup and its config entirely. - Keep one test in the app that decodes a sealed token under Expo's own
TextDecoder(expo/src/winter/TextDecoder), because Node's codec is only a stand-in. Seal it with a synthetic key; you can copy the reference encoder from this repository'spackages/erpgulf-server-lookup/tests/helpers/fixtures.ts.
Configuration gotchas
Each of these has caused a real production bug.
- The
$trap. The real secret contains a literal$. Expo's env loader runs dotenv-expand on every.envvalue, and quoting does not prevent it. So$Namesilently becomes the empty string, the key ships truncated, and every lookup fails withinvalidResponse. In.env, write it as\$. - EAS environment variables are stored literally and are not expanded. Enter
the plain
$there, with no backslash. Don't copy a.envline into the EAS dashboard. - Metro caches inlined env values. After changing any
EXPO_PUBLIC_*variable, runnpx expo start -corexpo export --clear. - On SDK 55,
eas updateneeds--environment; without it, the env vars are missing from the update. - Quote values containing
#in.env. - Rotating the key breaks every installed build until users update. The service would have to accept both the old and the new key during any overlap.
- A wrong key is provable: take a real token and try the candidate key with
decodeServerToken(token, validateServerLookupConfig(config)). A wrong key fails the checksum with probability 1 − 2⁻³². - Alphabet rules: exactly 64 unique printable ASCII characters (
!to}), with no., no~, and no space.
Development
From the repository root:
pnpm install
pnpm format:check
pnpm lint
pnpm typecheck
pnpm test
pnpm buildTests use a synthetic key and alphabet, stub fetch, and never contact a live
service. tests/helpers/fixtures.ts holds a reference encoder written
independently of the decoder, so the two check each other. It is not part of the
public API.
Live smoke test
scripts/smoke.mjs looks up a known company code against the real service and
exits non-zero unless it resolves to the expected backend. Never run it in CI,
and never commit the values. Build first, then enter the values in the shell:
pnpm --filter @erpgulf/server-lookup build
export SERVER_LOOKUP_URL='https://…'
IFS= read -rs SERVER_LOOKUP_SECRET && export SERVER_LOOKUP_SECRET
IFS= read -rs SERVER_LOOKUP_ALPHABET && export SERVER_LOOKUP_ALPHABET
node packages/erpgulf-server-lookup/scripts/smoke.mjsread -r takes a pasted value literally, so a $ in the key needs no escaping,
and -s keeps it off the screen and out of shell history.
