Alghafat MarketAlghafat MarketUAE

Accounts and admin

Guests and accounts, sign-in by email code, password or Google, mail, daily message limits, the first superuser, the admin panel, settings and stored keys, operator instructions, retention, proxies and every related variable.

The chat works without an account. Accounts add saved history, and one or more superusers run the deployment from the admin panel at /admin. Everything here works the same on the embedded database (PGlite) and on Postgres.

Guests and accounts

GuestSigned in
Live chatWhen Admin › Access allows guests (the default)Yes
Messages per dayA free allowance (default 20), then a card offers a free accountA larger allowance for cost control (default 200)
Chat historyNever stored on the serverSaved unless the chat is temporary or "Save new chats to history" is off
Search, rename, delete chatsNoYes
Watches and shortlistsKept under a random id in the browserKept with the account
UnitsKept in the browserKept with the account
Delete my dataWatches, shortlists and briefs of this browserChats, watches, shortlists, briefs and preferences; or delete the whole account

When someone logs in, what their browser saved as a guest moves into the account (watches, shortlists, approved briefs and any chats saved with the earlier consent box), and the browser gets a fresh guest id. Logging out also starts a fresh guest id, so the next person on a shared computer inherits nothing.

Signing in

/login (and /signup, which shows the same page) works like most AI chat apps: Log in or sign up with "Continue with Google" when it is on, then an email field. Continue sends a six-digit code to that address; entering it signs the person in, or creates the account when the address is new (while sign-ups are open). A new account is then asked "What should we call you?", which can be skipped. "Use a password instead" appears while password sign-in is on, and keeps the address typed so far. With Google as the only method, the page shows its button alone.

MethodNeedsOn by default
Email codeA mail transport (SMTP_URL and MAIL_FROM, see Mail), and in production TRUSTED_IP_HEADERWhen mail is available
Email and passwordNothingWhen email codes cannot work, so a fresh install without mail (or, in production, without a trusted client IP) still lets people in
GoogleGOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRETWhenever the keys are set

Admin › Access has a switch for each method and says what each one needs and whether this server has it; a method the server cannot offer (no mail, no Google keys, no trusted client IP in production) shows as off and unavailable. A change that would leave no working method is refused, and if the environment changes so that none works (mail removed, say), password sign-in stays on and the Overview reports sign_in_fallback. Every change to the switches is written to the audit log (auth.sign_in_methods, with the methods that now work). A switched-off method is refused by the server too, not only hidden.

  • Codes have six digits, are valid for 10 minutes, work once, and are void after 5 wrong tries. They are stored encrypted and compared in constant time. Asking again while a code is still valid (Resend, offered after 60 seconds) mails the same code with its wrong tries carried over, so someone asking for codes for another person's address cannot void theirs; a void or expired code is replaced by a new one. The answer to "send a code" is the same whether or not the address has an account and does not wait for the mail server; "sign-ups are closed" and "this account is suspended" appear only after a valid code. While sign-ups are closed, no code is mailed to an address without an account; the code step then says "If … has an account, we sent it a code", and /signup says at once that sign-ups are closed. Code requests are limited per client IP, per mailbox and client, and per mailbox whoever asks, by the hour and by the day (a mailbox is the address without a "+tag" before the @), and one client IP may enter at most 3 wrong codes for an address in 10 minutes (see the fixed limits). Someone who knows an address and has two or more client IPs can still void its pending code or use up its codes for up to an hour (or, spread over the day, until the daily count starts over): that delays code sign-in for whoever holds the mailbox but signs nobody else in, so a superuser should keep a second way in (a password or Google).
  • The email is in the language of the page that asked for it, in plain text and simple HTML, with the site's name (PRODUCT_NAME or the brand name, as on the login page), the code, its expiry and "if you did not ask for this, ignore this email"; it offers to create an account only while sign-ups are open. It has no images, tracking or links other than the site.
  • An address proven by a code owns the account. A password set before the address was proven (an email-and-password sign-up never proves it) is removed at the first code sign-in, together with the account's sessions, so whoever registered someone else's address first loses the account to whoever holds the mailbox. Superusers the CLI created or the setup page made count as proven; when upgrading, a migration marks existing ones as proven. An account the CLI promotes keeps a password set before its address was proven only with --keep-password; otherwise its first code sign-in removes that password and its sessions. Other password accounts from before email codes lose their password at their first code sign-in, and sign in with codes from then on.
  • Names have 1 to 80 characters, without line breaks, control characters or text-direction overrides; Settings and the name step can change the name only.
  • Email and password. Passwords need 10 to 128 characters. Signing up with a password signs you in at once.
  • Google. The first Google sign-in creates the account, so it follows the sign-ups switch too. Errors from Google come back to the login page as a message, never as a raw error page. Google tokens are stored encrypted and never used.
  • Forgotten passwords. There is no reset link. With email codes on, the person signs in with a code instead; otherwise a superuser sets a new password in Admin › Users. That signs the user out on every device, is recorded in the audit log and shows a notice at their next login until they change the password or dismiss it.
  • Settings › Account lets users change their name and, for password accounts, their password (which signs out their other devices). Deleting an account asks for the password; an account without one (email code or Google) needs a recent login instead.
  • Sessions last 30 days and are refreshed daily. Cookies are httpOnly and SameSite=Lax, use the alghafat-market prefix, and are Secure (with the __Secure- prefix) whenever the base URL is https. Roles, suspensions and admin access, private API calls and every page that shows account data (the chat list, saved chats, the account menu) are checked against the stored session, not the short-lived cookie cache, so signing out, signing out everywhere, a password reset or a suspension takes effect at once, and a removed superuser loses the admin panel at once.
  • Sign-ups can be closed in Admin › Access or with SIGNUPS=closed; superusers can still exist and log in.

Mail

Mail goes out through any SMTP server, set in the environment only:

SMTP_URL=smtps://user:password@smtp.example.com:465   # or smtp://host:25, smtp://user:password@host:587
MAIL_FROM="Market <no-reply@example.com>"
  • Without SMTP_URL no email is sent. In development the code is written to the server log instead ([dev] sign-in code for …); on the isolated test server it is appended to .data/pglite-test.mail.log. In production there is then no mail transport, email-code sign-in is unavailable, and codes are never logged.
  • An SMTP_URL that is not an smtp:// or smtps:// address, or a missing MAIL_FROM, switches mail off and shows up in the Overview (smtp_url_invalid, mail_from_missing).
  • In production, email codes also need TRUSTED_IP_HEADER (see Client IP): without a trusted client IP every code request would share one set of limits, and one client could use them up for everyone. Until it is set, email codes are not offered, password sign-in applies, and the Overview shows email_code_needs_trusted_ip. Behind a reverse proxy such as nginx, for example, set proxy_set_header X-Real-IP $remote_addr; and TRUSTED_IP_HEADER=x-real-ip (or x-forwarded-for with TRUSTED_PROXY_HOPS).
  • The Overview shows the transport and the host only; the user name and password in SMTP_URL never leave the server, and the env-only list shows SMTP_URL as scheme://host. Mail is sent in the background, so the sign-in page answers at once; sending times out after about 20 seconds, and a failure is written to the server log (the SMTP error code, not the message). Check the Overview and the log when codes do not arrive.

Daily message limits

WhoCounted perSettingDefault
Gueststrusted client IP and browser (and IPv6 network); whichever runs out firstAdmin › Access › Free guest messages per day, GUEST_DAILY_MESSAGES20 (0 = guests cannot chat)
Signed-in usersaccountAdmin › Access › Messages per day per signed-in user, USER_DAILY_MESSAGES200
  • Each user turn counts once, identified by a fingerprint that the server computes of the messages up to and including the last question, not by ids the browser chooses: a new question and an edited question each count as one message. What changes only the messages after that question (approving or declining a brief, regenerating the answer, retrying after a network error) runs the same turn again, which is free three times and then counts again, so made-up answers cannot buy model calls without end. Each allowance runs for 24 hours from its first message.
  • The browser part is the random guest id the chat already sends. The IP part needs TRUSTED_IP_HEADER; without it all guests share one IP count. An IPv6 guest is also counted per /48 network, with four times the allowance, so a client holding many /64s cannot rotate through them.
  • Upgrading: this allowance used to be a per-IP cap stored as guestMessagesPerDay, where 0 meant "no limit". It is now guestDailyMessages, where 0 means "guests cannot chat". A stored positive value carries over; a stored 0 is ignored (the default of 20 applies, or GUEST_DAILY_MESSAGES). The next save of Admin › Access stores the new field.
  • When a guest runs out, the chat answers 429 guest_limit with the reset time, and the conversation shows a card: "You've used today's free messages. Create a free account to keep chatting." with Sign up for free and Log in. The chat is carried across signing in (in this browser tab), the unanswered question goes back into the message box, and the chat is saved with the next message. From three messages left, a quiet line above the message box says how many remain. GET /api/chat/allowance returns { scope, limit, remaining, resetAt } for the caller.
  • A signed-in user who runs out gets 429 user_limit with the reset time.
  • The per-minute flood guards stay as before: per signed-in user, per guest IP, and for all guests together.

When accounts are off

In production, accounts stay off until BETTER_AUTH_SECRET (32 or more random bytes) and a base URL (BETTER_AUTH_URL, or else PUBLIC_BASE_URL) are set. They are also off while the database is unreachable. The chat keeps working for guests, Log in and Sign up are hidden, the login page says sign-in is unavailable, and /api/auth/* answers 503.

In development, the base URL defaults to http://localhost:3070 (or the PORT you set) and a secret is generated once into .data/dev-auth-secret, so restarts keep everyone signed in.

The first superuser

Two ways, both recorded in the audit log:

  1. The setup page. Set SETUP_TOKEN to 32 or more random characters (openssl rand -hex 32 works) and restart. Open /setup and enter the token, a name and an email address. With email-code sign-in on, a code goes to that address; entering it creates the account (or uses the existing one with that address), makes it the superuser and signs you in. Otherwise you also choose a password, and an address that already has an account is refused (promote it with the CLI instead). The page and its API (POST /api/setup) work only while accounts are on, the token is set and no superuser exists; otherwise the page shows "Page not found" and the API answers 404. Attempts are limited to 5 per 10 minutes before the token is even compared, and the token is compared in constant time. Remove SETUP_TOKEN when you are done.

  2. The CLI, from the repository root:

    pnpm --filter @alghafat-market/cli superuser --email you@example.com [--name "Your Name"] [--keep-password] [--yes]

    It reads next/.env.local and then next/.env (only variables that are not already set), so it uses the same database as the web app, prints that target (postgres://host/db or the PGlite directory), and asks for confirmation unless --yes is given. For a new account it asks for the password twice in the terminal (never as an argument); an existing account is promoted. If that account has a password but its address was never proven (an email-and-password sign-up), the password goes at its first sign-in with an email code, in case someone else registered the address; --keep-password keeps it valid instead, after saying so. It never demotes anyone. With the embedded database, stop the web app first: one process owns the data directory at a time.

The admin panel

Superusers see Admin panel in their account menu. Other signed-in users get "Page not found" on admin pages and 403 from the admin API; guests are sent to log in.

SectionWhat it controls
OverviewCounts (users, superusers, saved chats, chats active in the last 24 hours, watches, briefs sent), account, sign-in methods, mail transport (host only), model and data status, configuration problems, the env-only values (secrets only as set or not set) and the last changes
GeneralThe assistant's display name, the greeting and up to four suggestion chips per language
ModelProvider, model id, API key, base URL, maximum tool steps, Anthropic fallbacks. See Bring your own model
InstructionsThe locked core prompt (read-only), the operator instructions, a preview of the full prompt with its version id, and Retire older versions
ToolsA switch per tool, in registry order. plan is always on; send_brief always asks the user for approval
DataData source, source policy, listings API URL and token, emirate filter, cache and refresh times
AccessSign-in methods (email code, password, Google), guest chat, sign-ups, daily message limits, the per-minute limits, the maximum messages per chat, the geofence
Hand-offTest inbox or webhook, webhook URL and token, partner name and ORN, and the test inbox itself
ConversationsHow long saved chats are kept, "Delete expired chats now"
UsersEach account's sign-in methods; search, make or remove superuser, suspend (with a reason and a number of days) or lift, set password (only while password sign-in is on), sign out everywhere, delete
Audit logEvery change: when, who, what, which record
  • Changes to Model, Data, Hand-off and Conversations, Delete expired chats now, Retire older versions and every action in Users need a login from the last 15 minutes; the panel asks you to confirm it's you (with your password, a code sent to your email, or Google, whichever works for your account) and then retries.
  • The Users actions never apply to your own account (change your own password in Settings), and the last active superuser can never be demoted, suspended or deleted (Settings › Delete account refuses too), also when two superusers act on each other at the same moment: these changes take a lock on the superuser accounts first, so the second one sees the first.
  • There is no transcript viewer. Chats are stored in plaintext, though, so direct database access, or setting a new password for a user and logging in as them, can expose them; every password reset is audited and shown to the user.
  • The audit log records the actor's email at the time, the IP, the action, the record's id (never an email) and, for settings, the names of the changed fields. Secret values never appear; a stored key is logged only as "set" or "cleared". Entries are kept for a year.

How settings work

Each section of the admin panel is stored as overrides in the database. For every field the value comes from, in order: the admin panel, the environment variable, the built-in default. A badge next to each field shows which one applies (Set here, Environment, Built-in default), and "Use environment default" removes the override.

  • Changes apply within about 5 seconds, without a restart. Display name, instructions and tool switches apply to new chats (see below); everything else applies at once.
  • Each section has a version number. If someone else saved the section since you opened it, your save is refused and you reload.
  • If the database is unavailable, the app runs on env values and built-in defaults, and the Overview reports settings_unavailable.

Stored keys

The model API key, the listings API token and the webhook token can be entered in the admin panel when the server has SETTINGS_ENCRYPTION_KEY (base64 of 32 random bytes, or 64 hex characters). They are encrypted with AES-256-GCM (a fresh IV for every write, the field path bound in) and are never shown again: the panel shows only Stored (hidden) or Not set, with Replace and Remove. Without the key, keys come from the environment only. A stored key that cannot be decrypted (for example after the encryption key changed) falls back to the environment and shows up in the Overview as secret_unreadable:<field>.

Keys stay with their address. An env key is used only while the address it belongs to also comes from the environment or the default:

KeyBound to
AI_API_KEYModel provider and base URL
LISTINGS_API_TOKENListings API URL
HANDOFF_WEBHOOK_TOKENWebhook URL

Setting one of those addresses in the panel therefore needs the matching key in the same save (otherwise the save is refused), and the Overview shows secret_unbound:<field> while an env key is unused for this reason. In production, addresses set in the panel must use https and may not resolve to a private, loopback, link-local, CGNAT or unspecified address unless the server sets ALLOW_PRIVATE_UPSTREAMS=1. The check runs when you save; a DNS record that changes later is not caught.

Settings and their environment defaults

SectionFieldEnvironment variableBuilt-in default
GeneralDisplay name(none; falls back to PRODUCT_NAME, then the brand name)empty
GeneralGreeting, suggestions per languagenonethe translated defaults
ModelProvider, model, API key, base URLAI_PROVIDER, AI_MODEL, AI_API_KEY, AI_BASE_URLnone (replay mode); claude-opus-5-5 for Anthropic
ModelMaximum steps (2–24), fallbacksAI_MAX_STEPS, AI_FALLBACKS12, on
InstructionsOperator instructions (up to 4,000 characters)noneempty
ToolsSwitched-off toolsnonenone
DataSource, source policyDATA_SOURCE, SOURCE_POLICYsynthetic, allow-all
DataListings URL, token, emirateLISTINGS_API_URL, LISTINGS_API_TOKEN, LISTINGS_API_EMIRATEnone
DataCache (seconds), snapshot refresh (hours)LISTINGS_CACHE_TTL_SEC, LISTINGS_SYNC_TTL_HOURS21600, 24
AccessSign in with an email code, with email and password, with Googlenoneemail code when mail is available, else password; Google on (it works only with its keys)
AccessGuest chat, sign-upsGUEST_CHAT (off), SIGNUPS (closed)on, open
AccessFree guest messages per day, per IP and per browser (0 = guests cannot chat)GUEST_DAILY_MESSAGES20
AccessMessages per day per signed-in userUSER_DAILY_MESSAGES200
AccessMessages per minute per signed-in user, per guest IPRATE_LIMIT_CHAT_PER_MIN20
AccessMessages per minute from all guests togethernone60
AccessMaximum messages per chat (2–80)none80
AccessREST and MCP calls per minute per IPRATE_LIMIT_API_PER_MIN120
AccessBlocked countries for the live chatAI_GEOFENCE_DENYCU,IR,KP,SY
Hand-offDelivery, webhook URL and tokenHANDOFF_ADAPTER, HANDOFF_WEBHOOK_URL, HANDOFF_WEBHOOK_TOKENtest inbox
Hand-offPartner name and ORNHANDOFF_PARTNER_NAME, HANDOFF_PARTNER_ORNempty
ConversationsDays to keep saved chats (0 = until deleted)CONVERSATION_DAYS30

These stay env-only, because they are needed at boot, point at the filesystem or protect the server: DATABASE_URL, PGLITE_DIR, MARTS_DIR, TOOL_APPROVAL_SECRET, AI_EFFORT, PUBLIC_BASE_URL, BETTER_AUTH_URL, BETTER_AUTH_SECRET, SETTINGS_ENCRYPTION_KEY, SETUP_TOKEN, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, SMTP_URL, MAIL_FROM, TRUSTED_IP_HEADER, TRUSTED_PROXY_HOPS, TRUSTED_ORIGINS, ALLOW_PRIVATE_UPSTREAMS, LISTINGS_SOURCE_ID and the branding variables (PRODUCT_NAME and the others in .env.example). The Overview lists them read-only; secrets appear only as set or not set, DATABASE_URL only as scheme://host/db and SMTP_URL only as scheme://host.

Operator instructions and prompt versions

The core system prompt (how the agent works, numbers, stance and safety) is part of the code and never changes per deployment. What the admin panel adds goes into a separate block after it:

  • a fixed paragraph saying that the operator's notes set name, tone, focus and scope, and that the sections above win whenever a note conflicts with them;
  • Display name: and the list of switched-off tools, when set;
  • the operator instructions, inside <operator_notes> tags.

Instructions are checked as you type and again when you save:

  • Refused: more than 4,000 characters; the reserved <operator_notes> tag and any other tag-like markup (< followed by a letter); a word that mixes Latin letters with letters of another alphabet (Cyrillic, Greek, Armenian and so on; Chinese, Japanese and Korean next to Latin abbreviations such as AED are fine); and text that asks the assistant to recommend buying or selling, to tell users they qualify or are approved, to ignore or override its rules, or to hide that it is an AI. These phrases are recognised in English, Persian, Arabic, Russian and Chinese. A note that tells the assistant not to give advice ("Never recommend buying", "Не давай советов о покупке") is not refused.
  • Warnings: other phrases the stance checker flags, quoted as you wrote them, and text in a language the check does not read: letters of another alphabet (Hebrew, Greek, Japanese kana and so on), or Latin-script text with clear signs of a language other than English (its common words, elision such as d'acheter, accented letters). Such text gets only the length, markup and alphabet checks, so the warning asks you to read it yourself. Saving needs an explicit "I checked these". The same applies to the greeting, the suggestions and the display name in General.
  • Zero-width and direction-control characters are removed before the check, so they cannot hide a phrase; Persian and Arabic joiners (ZWNJ, ZWJ) are kept in the saved text. The check also ignores letter case, diacritics (Arabic short vowels, й and ё), the tatweel and the difference between Arabic and Persian kaf and yeh. Fullwidth and styled letters, and letters that the Unicode confusables data (UTS #39) lists as look-alikes of ASCII letters, are read as those letters, so a word written entirely in look-alikes is checked too.

Preview effective prompt shows the full text the model will get and its version id. Every combination of prompt and tool list is stored once as an immutable prompt version, and a chat uses the version it started with on every turn: edits reach new chats only, and the prompt of a running conversation never changes. Retire older versions asks guest and temporary chats that are still on an older version to start a new chat; saved chats keep working on theirs.

Tool switches apply to the chat only; the REST API and the MCP server keep every tool. A switched-off tool stays in the model's tool list, in the same order, and refuses to run; the model is told it is off. send_brief that is switched off is refused before any approval card appears.

Reasoning effort is not a setting: it comes from AI_EFFORT (default low) and is shown read-only.

Retention and clean-up

  • Saved chats expire after the retention period, counted from their last message. Lowering it in Admin › Conversations first shows how many saved chats would expire, and asks for confirmation.
  • An hourly job deletes expired chats, briefs older than the longer of 30 days and the retention period, expired sessions (which hold an IP and a browser name), rate-limit records older than a day, and audit entries older than a year. "Delete expired chats now" runs the whole job at once.
  • Deleting a user deletes everything stored with the account.

Client IP, proxies and origins

Per-IP limits read the client IP from one header only, named in TRUSTED_IP_HEADER: cf-connecting-ip, x-real-ip, x-vercel-forwarded-for or x-forwarded-for. For x-forwarded-for, TRUSTED_PROXY_HOPS (default 1) picks the entry counted from the right, the one your own proxy appended; the client-controlled part on the left is never used. Set it to what your proxy or platform writes and make sure the proxy overwrites that header. Make the app reachable only through the proxy or platform that writes TRUSTED_IP_HEADER, for example by binding it to 127.0.0.1 or firewalling its port; otherwise a client can write the header itself and get fresh limits for codes, sign-ups and guest messages. An IPv6 client is counted per /64 (the block one host usually holds), and an IPv4-mapped address as its IPv4 address. Code requests and guest allowances also count per IPv6 /48, with a larger share, since a home connection or tunnel can hold a /56 or a /48. Behind a reverse proxy such as nginx, have the proxy set the header (proxy_set_header X-Real-IP $remote_addr;) and set TRUSTED_IP_HEADER=x-real-ip; with Cloudflare in front, use cf-connecting-ip.

The geofence (Admin › Access › Blocked countries) reads the country only from the platform that TRUSTED_IP_HEADER names: cf-ipcountry with cf-connecting-ip (Cloudflare) and x-vercel-ip-country with x-vercel-forwarded-for (Vercel). With any other setting there is no trusted country header and the geofence blocks nothing, because a country header the platform did not write could come from the client.

Without a trusted header (the default, none), every guest shares one set of limits, the Overview shows no_trusted_ip_header in production, and email codes are not offered in production. Sign-in is protected either way: each email address gets at most 10 password attempts per 15 minutes whatever the IP, each code 5 tries, each account at most 5 password changes or account deletions per 5 minutes, and new accounts (by email code, password or Google) are limited to 5 per hour per IP, or 20 per hour in total without a trusted IP.

Fixed limits besides the ones in Admin › Access:

WhatLimit
Password sign-in per email address10 per 15 minutes
Codes sent per mailbox (the address without a "+tag")5 per hour per client IP; 10 per hour and 30 per day in total
Wrong codes per address and client IP3 per 10 minutes (only with a trusted IP)
Codes sent per IP20 per hour per IP, 60 per hour per IPv6 /48; 300 per hour in total without a trusted IP (development only)
Wrong tries per code5, then the code is void
Change password, delete account per account5 per 5 minutes
New accounts (email code, password or Google)5 per hour per IP; 20 per hour in total without a trusted IP
Password sign-up requests, whatever they return10 per hour per IP; 40 per hour in total without a trusted IP
Account endpoints (/api/auth/*)request bodies up to 16 KB; addresses up to 254 characters, 64 before the @
Account endpoints per IP (only with a trusted IP)password sign-in 5 per minute, sending a code 3 per minute, code sign-in 10 per minute, change password and delete account 5 per 5 minutes, others 100 per minute
/setup5 per 10 minutes per IP (or in total)
Admin API60 per minute per superuser

Rate limits are kept in memory per process; several instances need a shared store.

Requests that change data must come from an allowed origin: the origin of BETTER_AUTH_URL (or PUBLIC_BASE_URL) plus TRUSTED_ORIGINS, a comma-separated list for other addresses you open the app from, such as a LAN address. Outside production, localhost, 127.0.0.1 and [::1] on ports 3070 and 3075 (and PORT) are allowed too.

Variables

VariableMeaning
BETTER_AUTH_SECRET32+ random bytes. In production, accounts are off without it; in development one is generated into .data/dev-auth-secret
BETTER_AUTH_URLThe public base URL for accounts. Defaults to PUBLIC_BASE_URL; in production accounts are off without either
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETOptional Google sign-in; both or neither. The redirect URI to register is <base URL>/api/auth/callback/google
SETUP_TOKENOne-time superuser bootstrap at /setup (32+ characters); remove it after use
SMTP_URLAny SMTP server: smtp://host:25, smtp://user:pass@host:587 or smtps://user:pass@host:465. Without it no email is sent and, in production, email-code sign-in is unavailable
MAIL_FROMThe sender, Name <address>; needed with SMTP_URL
GUEST_DAILY_MESSAGESDefault of the free guest messages per day (default 20; 0 = guests cannot chat)
USER_DAILY_MESSAGESDefault of the messages per day per signed-in user (default 200)
SETTINGS_ENCRYPTION_KEYBase64 of 32 bytes or 64 hex characters; lets the admin panel store keys
GUEST_CHAToff keeps guests out of the live chat (default on)
SIGNUPSclosed stops new accounts (default open)
CONVERSATION_DAYSDays saved chats are kept after their last message; 0 keeps them until deleted (default 30)
TRUSTED_IP_HEADERnone (default), cf-connecting-ip, x-real-ip, x-vercel-forwarded-for or x-forwarded-for
TRUSTED_PROXY_HOPSFor x-forwarded-for: which entry to take, counted from the right (default 1)
TRUSTED_ORIGINSExtra allowed origins, comma-separated
ALLOW_PRIVATE_UPSTREAMS1 lets addresses set in the admin panel point at private or loopback addresses in production

For contributors: an isolated test server

node next/scripts/dev-test.mjs starts a second development server on port 3075 with its own database (.data/pglite-test), its own build directory, no model (add --with-model to keep the AI_* variables) and no outside integrations. It writes fresh secrets for each run, including a SETUP_TOKEN, to .data/pglite-test.env, and sign-in codes to .data/pglite-test.mail.log (both git-ignored). bash next/scripts/smoke-chatbot.sh then checks setup and sign-in with email codes, the password fallback, the admin API, the absence of secrets in responses, origin checks, the guest daily limit (against a local stand-in model it starts itself, next/scripts/mock-model.mjs) and other limits. To start over, stop the server and delete .data/pglite-test, .data/pglite-test.lock, .data/pglite-test.smoke and .data/pglite-test.mail.log.

Code: Apache-2.0. Original docs text and diagrams: CC BY 4.0. Excluded from both: data, figures and charts derived from DLD or Data.Dubai (governed by DATA_LICENSE.md and any DLD grant); EIBOR values (CBUAE terms); third-party names and logos; and the product and operator names and logos (no trademark licence).

The default data is SYNTHETIC – not Dubai data. Nothing here is financial, legal or investment advice.

On this page