Bicrypto 6.6.2

7 August 2026

This release has upgrade notes. Read them before updating — they describe behaviour changes that need your attention.

DEPENDENCIESMAINTENANCESECURITYCAPTCHABOT-PROTECTIONREGISTRATIONRATE-LIMITSUPGRADEEXTENSIONSUPDATESLICENSINGADMINAUDITDESIGNSUPPORTLIVE-CHATINVESTMENTPERFORMANCEBUILDTOOLINGFONTSICONSCHARTSFORMSDATESGEORECOVERYSTAKINGAFFILIATENFTICOBLOGLOCALISATIONROUTINGWALLETSTWO-FACTORACCESSIBILITYLINTTESTS

Core v6.6.2

Release Date: August 7, 2026 Tags: DEPENDENCIES, MAINTENANCE, SECURITY, CAPTCHA, BOT-PROTECTION, REGISTRATION, RATE-LIMITS, UPGRADE, EXTENSIONS, UPDATES, LICENSING, ADMIN, AUDIT, DESIGN, SUPPORT, LIVE-CHAT, INVESTMENT, PERFORMANCE, BUILD, TOOLING, FONTS, ICONS, CHARTS, FORMS, DATES, GEO, RECOVERY, STAKING, AFFILIATE, NFT, ICO, BLOG, LOCALISATION, ROUTING, WALLETS, TWO-FACTOR, ACCESSIBILITY, LINT, TESTS

Overview

Eight unrelated bodies of work ship together here: a large dependency update, a rework of add-on and platform updates, a set of guards that stop the geographic restriction feature from taking a platform offline, a redesign of the heading every admin and add-on page opens with, a rebuild of the support centre, of the conversation screen on both sides of the desk, and of the admin queue those conversations arrive in, a rebuild of the customer's investment product, a set of controls that let you change how the platform's components are proportioned rather than only how they are coloured, and the repair that lets the trading charts read the colours Site Design produces — one of them would not draw at all until it could. Alongside them is one fix worth reading on its own — admin forms would not accept a blank date, including the ones that invite you to leave it empty. It is in Upgrade Notes, because working around it changed when geographic restrictions take effect.

A ninth arrived late, prompted by an operator whose user list was filling with accounts nobody had signed up for: the platform's registration door had, in practice, no cost at all. The captcha was a proof-of-work puzzle whose single-use guarantee was not one, so a fan-out of simultaneous requests all spent the same solved challenge; a second route created accounts with no captcha and no rate limit whatsoever; and the per-route rate limiters that were supposed to back all of it up were counting every visitor on earth into one shared bucket. That work also brings a choice of captcha — Cloudflare Turnstile, reCAPTCHA v3 or hCaptcha — where before there was one. Five of its items are in Upgrade Notes.

The investment rebuild carries the item that needs reading before you update anything: every investment screen presented a plan's percentage as a gain, and the field that decides whether it is one was never sent to the browser. It is in Upgrade Notes, first, because a plan set to DRAW or LOSS has been advertising a return it does not pay.

Update Instructions

pnpm updator

Highlights

Dependencies

118 packages were behind, 61 of them by a whole major version. 67 are now updated, 20 of those across a major, and 41 are deliberately held back — each for a reason that was verified by running the package, not by reading its changelog.

That half adds no feature. What it does is stop the platform drifting away from the software it is built on, and along the way it fixed a handful of faults that only surfaced because updating forces every assumption to be re-checked.

Updates

Bringing an install up to date used to be an errand measured in clicks. The licence server issues releases one version at a time, and the only place to apply one was an individual add-on's own page — so an operator four versions behind on nine add-ons opened nine pages and pressed Update thirty-six times, waiting for each one to finish to know when the next press was safe.

Add-ons & Integrations now has an Update All button. One press takes every licensed add-on to its newest release, walking each one through every intermediate version in order, and shows you where each add-on is while it works.

Two faults were found while building it, and both are in Upgrade Notes: the platform could report an add-on as up to date when it had in fact failed to ask, and the endpoint that answers that question was reachable without signing in.

Both add-on screens were rebuilt on the way through, and between them they were carrying eight statistic tiles that reported nothing you could not already see on the same screen. The list opened with Total, Licensed, Unlicensed and Updates available; the add-on page opened with Version, Status, License and Updates — each one restating a badge sitting a couple of hundred pixels above it. They are gone, and what replaced them is a short row of figures on the list and a row of status chips on the add-on page. "Unlicensed" is not among them, deliberately: those rows are the catalogue the platform ships with rather than a set you installed, so the tile was reporting that not having bought twenty-two products was a warning condition — an alarm that fires on every install is how a warning becomes wallpaper.

The add-on page also stopped mistreating its own product illustration. These are wide, labelled images, and two of the three views drew them into a box 267 pixels wide, where their labels rendered at about five pixels — present, and unreadable. The picture is now the heading's right-hand column, drawn whole at the proportions it was made in. And the page had been arriving in pieces: twenty animated elements on a staggered ladder, the last of them settling a full second after the first, so anyone who scrolled on arrival watched the content they were scrolling toward slide out from under them.

The add-on page itself is three tabs now instead of four. Updates and Changelog read the same release notes from the same place and differed only in which version each one picked — that is a selector, not a second screen — and between them they put those notes inside three fixed-height windows on a page that already scrolls, so the document you opened the tab to read was cut off mid-sentence. They are one Releases tab, the notes run to their full length, and the version list beside them now says which releases you already have and which are ahead of you. It could not say that before: it looked for the running version in the published list by exact match, and an add-on's installed build is frequently not in that list at all.

What was the Overview tab is worth a paragraph of its own. It held six controls across two panels — one of which repeated the first entry of the panel below it — and then about 300 pixels of empty page. It now answers the questions an operator actually opens a licensed add-on to ask: what it is, where it lives, what version it is on and whether that is current. The "where it lives" part is new information rather than a rearrangement: the licensed view had only ever listed an add-on's admin pages, by label, with the address hidden — so the one screen you reach after switching an add-on on could not tell you the addresses your customers would be visiting.

Four products in the catalogue turned out to have no picture at all, and three of those no description either: Binance US, Kraken, OKX and MO Chain. They are bundled rather than sold, so nothing upstream had ever produced artwork for them, and they sat in the grid drawing a bare category icon beside seven siblings that had a full illustration and a sentence. All four have a picture now, the three exchange providers have a description, and opening any of them describes the product instead of showing one line saying it needs a licence.

The platform's own update screen had all of the same faults and three worse ones. System → Update answers one question — is this platform current, and if not what is waiting — and it answered it wrongly whenever it could not reach the licence server.

There are three reasons that screen can report no update: the licence server said so, there is no purchase code on this install, or the question failed. The first is good news; the other two are not news at all. It drew all three as a green tick over "You're Up to Date!", and where a reason existed it printed that underneath as though it were the explanation of the good news — so "Unable to retrieve update information due to a network error" appeared as the caption to a success. An install that had been unable to ask for a month looked exactly like an install that was current. The distinction was already being made underneath, for the bulk add-on updater, which needs it to know when to stop; the screen simply never read it. It does now, and it also reports when it last managed to ask, because that answer comes through a ten-minute cache and a verdict with no timestamp cannot be argued with.

Three more things on that screen produced no feedback whatsoever. A purchase code the licence server rejected did nothing — no message, no error, the button just stopped spinning, and a mistyped code, a code already bound to another machine and an unreachable licence server were indistinguishable. A failed update did nothing, because the reason was written into a field the screen does not draw, and it carried on offering the same update as though the press had not happened. And a successful update's confirmation could vanish a second after it appeared: when more releases were queued, the check that runs next overwrote it, taking the count of files replaced with it — the only evidence that the most consequential button on the platform had done anything at all. There was also a fourth silence with no button attached to it: if the backend could not report what version this install is, the screen stayed a loading skeleton forever, with no message and nothing to press, which is precisely the state an operator lands in while their backend is restarting.

The rest is what the add-on screens got. Three hand-built copies of one layout that disagreed with each other; three statistic tiles restating three badges a couple of hundred pixels above them; Updates and Changelog drawing the same notes from the same place; those notes shown through fixed-height windows that cut them off; a version list that could never mark the build you are running. And on the unlicensed screen, a Platform Features panel written in English only — on the one screen an operator in another language reaches before they can use the product — offering P2P Trading and Staking among its six reasons to license the platform, both of which are separately licensed add-ons that this licence does not include.

Geographic restrictions

An operator restricted one country and took their entire platform offline — every visitor, every administrator, and themselves. What they were shown while it happened was their own compliance notice, which is precisely what a correctly restricted country looks like, so nothing on screen suggested a misconfiguration.

The cause is two settings that are individually reasonable. Block when the country cannot be determined is a sensible thing to want. No IP geolocation provider is the default. Together, on a server with no CDN in front of it, they mean that nobody's country can be determined and therefore nobody is let in — and the country rules are never reached at all, so nothing on the restrictions page has any effect on it.

Nothing in the product could have warned them, because the missing fact is not one a setting can answer. Trusting CDN country headers does not put a CDN in front of your server. Choosing a lookup provider does not make it reply, keep its key valid, or see past a reverse proxy. Whether an install can actually determine a country is a question only live traffic can settle.

So the platform now measures it. It refuses to save the two configurations that refuse every request as a matter of arithmetic, warns before saving the ones that merely lock you out, switches the offending setting off by itself if detection breaks after a safe save, and ships a command that repairs the policy from the database for the case where the admin panel is the thing being blocked. All four are in Upgrade Notes.

Page headings

More than 250 admin and add-on pages each chose their own colour, and none of those choices meant anything. A heading could ask for a hue by name and get two blurred glows, a canvas of drifting particles and a gradient poured through its own title — so the payment gateway console read blue, the staking pool console read green, and the two looked like software from different companies. Half the names available resolved to the palette the platform reserves for status, which is how the market news screen shipped permanently coloured "success" and the ecommerce order history permanently coloured "warning" — on pages that report no status at all.

None of it was reachable by the checks that keep the rest of the product on its palette, because a colour handed to a renderer as a value is not a colour the design tooling can see. It survived two migrations that way.

Every page heading is now the same heading, on the background the peer-to-peer market and the support console already used: a faint hairline grid and a single soft light source, spent in the top few hundred pixels and gone before your data starts. One accent, and it belongs to the brand rather than to the page. The result is that a table, a gallery and a dashboard in the same section finally look like the same product.

Three practical gains come with it. Headings paint immediately rather than sliding into place up to a second after the content beneath them. The figures under a page title are a compact rail instead of a grid of full statistic cards, which on the payment gateway console cut the heading from 260 pixels to about 100 and lifted the alert that screen exists to show back above the fold. And every one of those headings had been running an animated canvas continuously for as long as the page was open; they are gone.

Component design

Site Design could change what the platform is coloured. It could not change what it is proportioned like. Colour, typeface, corner radius and motion were the entire vocabulary, so an operator who wanted denser tables, squarer controls, flatter cards or finer chart lines had nowhere to ask — those things were not settings at all. A table row was 48 pixels because 48 pixels was written into the table, and a button had seven heights because seven heights were written into the button.

Site Design now has a Components group: five panels — Tables, Buttons & inputs, Cards, Forms & dialogs, Charts & metrics — carrying 27 controls between them. Row height, cell padding, header weight, striping and row rules; control height, padding, corners, label weight and border width, with badges on their own corner setting; card padding, border and elevation; field spacing, label weight, dialog padding and scrim strength; chart series width, gridlines, data-point size and metric-tile proportions.

Each panel opens with a row of named starting points — Compact, Roomy, Striped and Ruled for tables; Rounded, Square, Heavy and Compact for controls; Flat and Outlined for cards — 23 in all, including an "As shipped" in each panel that clears whatever you have set. They are a starting point rather than a mode: adjust anything afterwards and the panel says so.

Every control ships at the value the product already uses, so nothing about your platform looks different until you move something. Corners are the one place they compose rather than replace — a control's corner setting multiplies the radius you chose on the Presets tab, so squaring the platform still squares the buttons.

Two things were needed to make any of it judgeable. The design preview can only load public pages, and there is no table, dialog, form field or metric tile anywhere on the public site — so a specimen page was added that draws every component the settings reach, in every variant and state it ships in, and the preview switches to whichever family you are editing. And the settings themselves are stored the same way the palette is: exported with your theme, imported with it, and applied the moment you press Save rather than on the next reload.

Support centre

The customer's side of support was two pages that did not agree about anything. /support was a lobby holding two buttons and a preview of four conversations; /support/ticket held the real list, the search and the filters. Everything on the lobby was either a link to the room behind it or a worse copy of what was already in there — and to offer that link it replaced the platform's whole navigation bar with a two-item one, whose first entry pointed at the same place the main menu already did. Both entries lit up at once as a result.

It is one support centre now, reached from the menu the rest of the platform uses. /support/ticket redirects to it.

The list itself was reporting figures it could not know. It asked for your tickets without saying how many, the platform answers that question ten at a time, and every number on the screen was then counted from those ten — the totals, the search, the filters and the pager alike. A customer with forty tickets was told they had ten, and the four they were looking for could not be found by searching. Beside those figures sat three that were never measured at all: a "+12% this month" trend, and a 4.9 rating and 2.5k resolved count printed for whichever agent was assigned.

What replaced them is the question a support list actually answers: whose turn is it. Conversations are grouped into waiting on us, waiting on you, and resolved, counted in the database rather than in the browser.

Raising a ticket moved out of a modal and onto its own page, one conversation became a full-screen app, and the live-chat widget was rebuilt — it had been creating a ticket in your desk queue every time a customer merely opened it, discarding every image anyone attached, and telling them an agent was "Online" when what it had actually detected was their own browser's connection.

Nine faults are in Upgrade Notes, three of which change what you see in the admin support queue.

The conversation itself, on both sides of the desk

A support conversation is one object with two screens on it, and both were built the same way: the transcript, and a column of facts beside it reporting when the ticket was created, how long the desk had taken and whether the connection was up. Every one of those is a fact about the conversation. None of them is help with it — and meanwhile the things that actually resolve a case were 10-pixel chips inside a message that scrolled away.

On the customer's side that column now carries the answer's sources. When something answers with a help article it also records the passage it relied on, and that passage has only ever existed as a tooltip — invisible on every touch screen the platform ships to. It is now a readable quote on a card that stays put after the message scrolls, so a customer can check an answer against what it was drawn from without leaving the conversation. The ticket's own details move to a third tab, and the satisfaction rating becomes a question rather than a readout: the platform has always been able to display a rating and had no screen anywhere that could collect one.

On the operator's side the same column becomes the desk. The AI support inbox could tell you a great deal about a conversation and let you do almost nothing with it: changing its status or its assignment meant leaving for the CRM ticket screen — which shows an assistant's reply wearing the assigned human's photograph — and its priority could not be changed from anywhere at all, having been settable only by the customer who raised it. Those controls are now beside the conversation, along with the two things an operator taking over actually needs first: why it reached them, in the words a person would use rather than the raw reason the screen had been printing, and what the assistant had already established.

Three faults found there are in Upgrade Notes, and the first of them is the reason to read that section before you update. Switching conversations changed which ticket Send posted to immediately, while the box kept the text belonging to the conversation you had just left — so a reply could go to the wrong customer. The other two are of a kind: the conversation pane was a static snapshot — the queue refreshed every twenty seconds, the open conversation never did, under a live indicator that had been telling the truth about the list and not about the transcript — and nothing on the screen said when another agent already had a conversation, on a queue whose ordering guarantees two operators land on the same escalation.

The admin support desk

CRM → Support was a row of statistic tiles over a paged table of tickets. Every column in it was a fact about a ticket — customer, agent, subject, priority, status — and none of them was the conversation, so the only thing an operator could do on that screen was decide which row to leave for. Answering one customer cost a full page load out to the ticket screen and another one back, and the queue you had been reading was gone both times.

It is now the same three-pane console as the AI add-on's Live Inbox — the queue on the left, the conversation in the middle, the ticket on the right — with none of the AI in it. You read the oldest thing waiting, answer it, set its status and priority, assign it and close it without the screen ever changing, and J/K walk the queue without reaching for the mouse.

The ordering is the part worth explaining, because it is the one thing a table could not do. That table sorted by when the ticket was opened, which is not the same question as who has been waiting longest: a ticket raised a year ago and answered yesterday sorted above one raised this morning and unanswered since. The queue sorts on the age of the last message — how long this customer has been waiting for this answer. Conversations the desk has already answered are not hidden, they are demoted below the ones that have not, so an operator can still find the reply they sent two minutes ago.

The console also holds a live connection on every conversation. The ticket screen only ever did that for live chats, so an agent reading an ordinary ticket did not see the customer's reply arrive — they had to reload to find out, on the one screen where the customer is waiting.

The table did not go away; it moved. Everything a console is the wrong shape for — search across the whole desk rather than the queue in front of you, filters and sorting on every column, bulk close, reopen, assign and delete, the analytics tabs and the totals over them — is at CRM → Support → All tickets, one press from the console's top bar. The statistic tiles went with it, deliberately: they count the whole desk, and the console shows the live end of it, so the two would have been a pair of numbers claiming to be the same thing.

One block at the foot of the queue is about the AI Support add-on. If you have it, it is a door to its Live Inbox, where these same conversations carry the assistant's evidence and a takeover button. If you do not, it says what the add-on does — and it is hidden entirely from roles that cannot open the add-ons screen, rather than showing a support agent a locked door on every shift.

Sorting and paging, on both support queues

Both consoles opened on a fixed slice of the desk and put it in order afterwards, in the browser. That is the wrong way round: the database was choosing which conversations you got, and the ordering only rearranged the ones it had already been handed. The two screens disagreed about which end of the desk to keep, and each kept the wrong one. The AI Live Inbox asked for the fifty oldest conversations, so past fifty a new escalation was not below the fold — it was not in the answer at all. The support desk asked for the sixty most recently touched, and its own ordering puts the longest wait first, so the rows it exists to raise to the top were the rows it dropped. Neither screen could tell you it had done either.

Both now order in the database, before anything is cut, and page. Under the search box on each is a sort control with an arrow that reverses any choice. The default stays what it was — longest wait first on the desk queue, most urgent first on the AI inbox — and beside it are last activity, ticket created, importance and customer on both; status, response time and rating on the desk queue; AI answers and cost on the AI inbox. The choice is remembered per browser, because it is a way of working rather than a per-visit decision. The foot of each queue says which rows you are reading and how many there are. The search box and the tab counts moved to the database with the sort, so searching means searching every page rather than the one in front of you, and the number on a tab is the number of conversations in that tab rather than in the fetched slice — which is also why Closed can finally carry a count on the desk queue, having been the one tab that could not.

On the desk queue this needed a change to the ticket record itself. Who spoke last, and when is the queue's sort key, and it lived inside the conversation, which is why it could only ever be worked out after the rows had been fetched. Two fields now carry it, written whenever a message is written and filled in for your existing tickets when you update.

The AI Live Inbox gained a Closed tab in the same work. A resolved conversation had no screen anywhere in the add-on, so there was nowhere to review the one you had just finished — and reopening a closed ticket put it back in the desk queue while leaving it hidden in the add-on's for good, because closing a ticket ends its assistant session and nothing was ever written to start one again.

The investment product

Every plan in Finance → Investment → Plans carries a Default Result you are required to choose — WIN, DRAW or LOSS — and it is the field that decides what happens to a customer's money when their term ends. WIN returns the principal plus the plan's percentage. DRAW returns the principal and pays nothing. LOSS returns the principal minus that same percentage.

That field was never sent to the customer's browser. The endpoints behind the investment pages select an explicit list of columns and it was not on the list, so no screen could have shown it even if it had wanted to. What every screen showed instead was the percentage on its own, labelled "Expected return", with an "Expected profit" and a "Total return" calculated from it and a line underneath promising a security guarantee — on plans that may be configured to pay nothing, or to take that percentage off the top.

The customer's side of the product has been rebuilt around telling the truth about that. Every plan now states its rate and its rule together, everywhere the rate appears; the projection on the invest form is signed by the rule and withheld entirely where an older backend does not report one; and the phrase "expected profit" is gone from a product that can settle a loss.

The rest of the rebuild follows the peer-to-peer pattern from the previous release. Four destinations became three — the dashboard and the history table were the same rows behind two menu entries, and are now one My investments page with running positions first. A position has its own page for the first time, which is where two things live that had no home: the clock, so a customer can see how long a term has left and the exact date it matures, and Cancel, which returns the principal in full.

Cancel is the one worth pausing on. The route that performs it has existed for as long as the product has, refunds correctly, and nothing in the customer's interface has ever called it. There was no way to leave a position early.

Eight faults are in Upgrade Notes, four of which change figures your customers have already been shown.

Chart colours

The platform draws its trading charts two different ways — the licensed terminal on the exchange screens, and its own chart engine on binary, futures and the swap terminal. Neither of them could read the colours Site Design produces.

Colour in the design system is stored in the form a stylesheet wants. Both chart renderers parse colour themselves rather than letting the browser do it, using code written before that form existed — and they failed in opposite ways. The licensed terminal refuses to start on a colour it cannot read: it works out the contrasting ink for the chart background while it is still building the chart, so one unreadable background is a chart that never appears at all, and a line in the browser console about a colour string it does not recognise. The chart engine's own parser had no such objection. It quietly returned mid-grey and carried on, which is why the band fills on Bollinger Bands, the Ichimoku cloud, volume profiles and every filled drawing shape had been painting grey without anyone reporting a fault.

Both now go through one translation step, which turns anything the browser can paint — the platform's own format, a colour picked in Site Design, a hex value, or one of the newer CSS colour spaces — into the form the chart understands. Where a colour genuinely cannot be translated, the chart keeps its own default for that one value rather than refusing to draw. That is what makes the repair hold: the palette can change shape again without taking a chart down.

Two more faults were found underneath. The exchange terminal reapplied your palette at the same moment it switched between light and dark rather than after it, so the theme it was correcting overwrote it — losing the palette every time a chart opened. And the chart engine's default volume indicator was being written into the customer's browser with its colours already resolved, pinning it to whichever palette was on screen the first time that person opened a chart.

Bot protection

An operator asked why their captcha was not stopping the accounts arriving in their user list. The answer was that four separate things had to be true for it to stop anything, and none of them were.

The captcha could not tell a person from a script, by design. Proof-of-work is a price — it asks a browser to burn CPU — and it was the only option the platform offered. It never asked a question a human answers differently from a program; it only charged for the attempt. Worse, it charged the wrong party more: a real customer pays through the browser's own hashing, one call per attempt, while an attacker pays native code roughly an order of magnitude cheaper per core and far more than that per machine. Both sides scale identically with difficulty, so the PoW Difficulty dial never changed the ratio — it slowed customers down and left the economics exactly where they were.

Its single-use guarantee was not one. A solved challenge was read at the top of the check and deleted forty lines later, with nothing in between to stop a second request reading it in the gap. Requests are served concurrently, so the gap was the whole of that read. Measured against a running install, six simultaneous registrations carrying one solved challenge saw three of them accepted — which made the real price of an account one solve divided by however wide an attacker opened the fan-out.

A second door created accounts with no captcha and no limit at all. GET /api/auth/login/chat took an email, a password and a name from the query string and returned a live fourteen-day session on an account it marked as email-verified. It had no callers anywhere — not in the platform, not in the add-ons, not in any test — and because it was a GET it also sat outside the platform's rate limiter entirely, at any setting. It is removed.

And the limiter meant to back all of this up was one bucket for the world. Named rate limiters — the ones on wallet login, password reset, verification resends and two-factor codes — read the caller's address from properties that do not exist on the request object they were handed, so every one of them resolved to the same key. Wallet login is five attempts per fifteen minutes: six wallet logins anywhere on earth refused wallet login for everybody. Password reset was three per fifteen minutes for an entire install. Registration, meanwhile, was the one auth route with no per-route limit at all, while eight of its siblings had one.

Captcha Provider is now a setting. Cloudflare Turnstile — free, unlimited and invisible to nearly every real visitor — is the recommended choice, alongside Google reCAPTCHA v3, hCaptcha, the built-in proof-of-work, and none. Every token is verified server-side against four things rather than one: that the provider accepts it, that it was minted for your hostname rather than an attacker's own page, that it was minted for the form being submitted, and — for reCAPTCHA, which never fails a request and grades it instead — that its score clears your threshold.

What happens when a provider cannot be reached is a deliberate split, and it is in Upgrade Notes: registration refuses, login and password reset carry on. You sign in through the same login route your customers do, so a mistyped secret key that blocked it would lock you out of the screen holding the setting you need to fix.

Registration now carries its own budget — three accounts per address per hour, and the same budget covers signing up with Google, which had no captcha and no limit of its own either.


Upgrade Notes

A route that created accounts with no captcha and no rate limit is removed

GET /api/auth/login/chat accepted an email, a password and a name in the query string and answered with a live session on a newly created account, which it marked as email-verified. It required no sign-in, ran no captcha, and — being a GET — was not counted by the platform's rate limiter at any setting. One request in a browser address bar created a working account.

  • Removed the route. It had no callers anywhere: not in the platform, not in any add-on, not in the test suite, and not in the documentation.
  • What to check: if junk accounts have been appearing, this is the most likely door. They are distinguishable — accounts created this way are marked email-verified without a Google sign-in behind them, where an ordinary signup starts unverified. In CRM → Users, an account with a verified address, no linked provider and no sign-in history came through here.
  • If you have built something outside this repository that called it, it needs to move to POST /api/auth/register. Nothing else on the platform used it.

Registration is now limited to three accounts per address per hour

  • Added a per-address budget on POST /api/auth/register and on Google signup, which shares it. Registration was the only route in the auth section with no per-route limit while eight of its siblings had one.
  • It fails closed: if Redis is unavailable, registration is refused rather than allowed through. A customer who cannot register retries in a minute and loses nothing; an account created during an outage cannot be un-created.
  • Added a budget of thirty login attempts per address per fifteen minutes, which fails open for the opposite reason — a Redis outage must never lock existing users, you included, out of the platform. It sits alongside the per-account lockout, which is unchanged and remains the control that stops brute force.
  • What to check: if your customers reach you through a corporate proxy, a campus network or a single NAT address, they now share these budgets. Confirm TRUST_PROXY is correct before you update — see below.

Every per-route rate limit was one shared bucket, and they are all changing

The named limiters — wallet login, password reset, verification resend, two-factor codes, account deletion — resolved the caller's address from properties that do not exist on the request object they were given. All of them therefore counted into one key, shared by every visitor.

The limits are sized as though they were per-caller, so the effect was severe rather than merely wrong:

  • Wallet login is five attempts per fifteen minutes and fails closed. Six wallet logins anywhere in the world, in any fifteen-minute window, refused wallet login for everyone — including a first attempt by somebody who had never used it.

  • Password reset was three per fifteen minutes for the entire install.

  • Login one-time codes were six per fifteen minutes, likewise for everyone.

  • Fixed the address resolution, so each of these now meters the caller it was always meant to.

  • These features will start working for people they had been silently refusing. If you have had reports of "the wallet login button does nothing" or "I never got my reset email" that you could not reproduce, this is the mechanism — it depends on what strangers did in the preceding fifteen minutes, so it is unreproducible by design.

  • Authenticated routes were never affected. They key on the account, which always resolved correctly.

Confirm TRUST_PROXY before you update, because the limits above depend on it

Every limit on this page is per IP address, and behind nginx or Cloudflare the address the backend sees is the proxy's unless TRUST_PROXY="true" is set and your proxy overwrites X-Forwarded-For.

  • With it wrong in one direction — a proxy in front and TRUST_PROXY off — every visitor shares one address, and the platform will now refuse everybody as soon as any three of them sign up.

  • With it wrong in the otherTRUST_PROXY on and a proxy that appends to the header rather than replacing it — a caller sends their own X-Forwarded-For and picks their own bucket, which makes every per-IP limit on the platform unenforceable.

  • Changed the SSL install page, which had been showing proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; — the appending form, and the vulnerable one. The nginx page had always shown $remote_addr, so the two pages disagreed and an operator following the SSL step landed on the wrong one while being told it was correct.

  • What to do: check the directive in your live nginx configuration. It should read proxy_set_header X-Forwarded-For $remote_addr;.

Your captcha may not have been running at all, and there is now a choice of provider

Proof-of-Work Captcha was a switch, and the backend read it as on only if a row for it exists and says so. The settings table ships empty — no installation seeds it — so on any install where nobody had opened that screen and pressed Save, the answer was no row, and the captcha did not run. The screen showed the switch on throughout, because it draws an unsaved switch at its shipped default; and because a save only transmits settings you have changed, agreeing with that default could never write the row that would have made it true.

  • Fixed the reading, so an absent row now means the shipped default — proof-of-work — rather than nothing.
  • Changed the switch into Captcha Provider, a choice of Cloudflare Turnstile, Google reCAPTCHA v3, hCaptcha, proof-of-work, or none.
  • Your existing choice is preserved. An install that explicitly switched proof-of-work off stays off; one that never touched it comes up on proof-of-work.
  • What to do: switch to Turnstile. It is free and unlimited, it is invisible to nearly every real visitor, and it needs a free Cloudflare account and two pasted keys — no DNS change. Bot protection and captcha has the steps for each provider.

If a captcha provider goes down, registration stops and login does not

A hosted provider can fail to answer: an outage, a DNS blip, a secret key pasted into the wrong field. That is not "this visitor is a bot" and not "this visitor is human" — it is no answer, and the platform now treats the three forms differently on purpose.

  • Registration refuses, with "Registration is temporarily unavailable."
  • Login and password reset carry on, and an error naming the reason is written to the log.

The asymmetry is deliberate. You sign in through the same login route as your customers, so a wrong secret key that blocked login would lock you out of the admin panel holding the setting you need to fix — with no way back in. Nobody is locked out of anything by being unable to create a new account.

  • What to check after changing keys: search the backend log for CAPTCHA. A wrong secret key produces a site that looks like it is working — registration says "temporarily unavailable", login behaves normally — and rejected our SECRET KEY is the line that names it. Setting the provider back to Proof of Work restores signups immediately while you sort the keys out.
  • Any released mobile build that only understands the old proof-of-work field keeps working while the provider is proof-of-work, and stops being able to register the moment you switch. Ship an updated client first.

Check every investment plan's Default Result before your customers see the new labels

Default Result is a required field on every investment plan and it decides the payout. Until now nothing on the customer's side of the platform read it, so a plan set to DRAW or LOSS advertised its percentage exactly like a plan set to WIN.

After updating, that field is on screen. A plan now reads "12% · Pays the rate", "12% · Returns principal" or "12% · Deducts the rate" on the plans list, on the plan's own page and on every position opened against it.

  • Changed the two plan endpoints to include defaultResult, and every customer-facing surface to state the rule beside the rate.
  • What to do: open Finance → Investment → Plans and read the Default Result column before your customers do. A plan you meant to pay out but left on the form's other options will now say so plainly, where previously the discrepancy was invisible to everyone including you.
  • Nothing about settlement changed. The engine has always paid according to this field; only the description did.

Four figures your customers were shown were wrong, and will change

All four are on the investment screens and all four move after updating.

  • Fixed a loss displayed as a gain. The amount stored against an investment is written unsigned — the same positive number whether the outcome added it or took it away — and the dashboard printed it in green with a plus sign on both. A customer whose plan settled LOSS was shown the amount they lost as profit earned.
  • Fixed "Total portfolio value" and "Total invested" counting cancelled and rejected investments. Principal refunded to a wallet months ago still appeared as capital at work, so both figures read high on any account that has ever cancelled.
  • Fixed those totals being added up across currencies and printed with a dollar sign. An account holding a BTC plan and an NGN plan had the two summed into one number — 0.5 and 40,000 reported as "$40,000.50". Totals are now reported per currency, because the browser has no exchange rates and inventing one is what produced that figure.
  • Fixed a brand-new investment showing a profit. The purchase writes the full projected return to the record at the moment the position opens, and the dashboard read it as earned — so a position opened a minute ago already reported its whole return as banked. Realised figures now appear only once a position has actually settled.

Customers can now cancel an investment, and the principal is returned

The route that cancels an investment and refunds the principal has existed since the product shipped. It had no caller anywhere in the customer's interface, so the only way out of a position was to wait for the term to end and take whatever the plan's Default Result gave.

  • Added a Cancel investment action on a position's own page, which is the existing route's first caller. It returns the principal to the customer's wallet in full, closes the position and emails them. There is no partial settlement — cancelling forfeits the outcome, which cuts both ways, and the confirmation says which way it cuts for that plan.
  • Cancellations were effectively impossible before and are now one click, so expect some. Each one is a refund of principal only; the platform neither gains nor loses on it.
  • Only running positions can be cancelled, which is the rule the route has always enforced.

The investment dashboard and history pages are now one page

  • Changed /investment/dashboard and /investment/history into My investments at /investment/portfolio. Both old paths answer with a redirect, so existing links, bookmarks, the main site menu and any email you have sent continue to work.
  • They were two menu entries onto the same rows from the same request. The dashboard also capped its list at five while fetching a hundred, so a customer with six investments could only reach the sixth through a "view all" link that appeared conditionally.
  • Added /investment/<id>, a page per position — the term, how long is left, the maturity date, what it settles as, and the cancel action.

Investment completion emails and notifications pointed at a page that does not exist

  • Fixed the settlement notification linking to /investments/<id>. There is no such route on this platform and there never has been — the product is at /investment — so every "View Investment" button on every completion notice this platform has ever sent landed on a not-found page.
  • The singular address was equally dead until this release, because no per-investment page existed at all. It does now, and it is the screen that notice is about.

The investment landing page was blank for anyone not signed in

  • Fixed the public investment page rendering nothing at all to a logged-out visitor. The whole page sat behind a verification check, and that check draws nothing while it does not know who is asking — so the marketing page for the product showed an empty screen to precisely the audience it exists for.
  • Both endpoints it reads are public, so nothing about the data required the gate. Verification is still enforced where it applies: on opening a position, and by the platform when the request arrives.

Investment plans no longer carry a risk rating, because the platform never had one

  • Removed the High Yield and Low Risk filters from the plans list. Neither was measured. "Low Risk" meant the plan's advertised return was 10% or under and "High Yield" meant over 15% — the platform was reading a return figure and printing a risk classification from it, on a financial product, with no risk field anywhere in the schema.
  • Changed the filters to what the plans actually record: the currency a plan accepts, and whether it is flagged trending.
  • Removed a "your investment is protected by our security guarantee" line from the invest form, on a form that can open a position which settles at a loss.

Your investment plan images will be laid out differently

  • Changed how a plan's image is drawn. The upload bounds an image to 1024×728 fit inside, which preserves whatever proportions it was uploaded at and never crops — so plan images in the wild range from square avatars to wide screenshots. The customer's pages had been cropping them to a fixed band, which cut over half the height off a square.
  • Plan images now appear as a contained thumbnail beside the plan title, the same treatment the admin's own plans table uses, and are never cropped.
  • A plan whose image file is missing from disk shows a small marker on the list and nothing at all on its own page. If you see that marker, the row points at a file that is no longer there — re-upload it from the plan's admin form.

A reply typed in the AI support inbox could be sent to the wrong customer

Selecting a different conversation in AI Support → Live Inbox changed which ticket the Send button posted to immediately, while the message box kept the text belonging to the conversation you had just left — that text was only replaced once the new conversation finished loading. Sending in that window sent one customer's draft to another customer.

The window is the length of one request, so it needed a deliberate sequence: switch conversation, then press Send or the keyboard shortcut before the new conversation had finished arriving. It is wider on a slow connection, and the inbox is worked at speed with a keyboard, which is what makes it reachable rather than theoretical.

  • Fixed the composer, the conversation on screen and the ticket being acted on being able to disagree. Selecting a conversation now clears the previous one immediately, a response that arrives for a conversation you have already left is discarded rather than painted, and Send is refused outright while the screen and the selection do not match.
  • If a customer has ever reported receiving a reply that was clearly meant for somebody else, this is the mechanism, and the conversation it was meant for is the one that was open immediately before.

The AI support inbox was showing a conversation that had stopped updating

The queue refreshed itself every twenty seconds. The open conversation did not refresh at all — it was read once, when you selected it. A customer replying, the assistant finishing an answer, or another agent taking the conversation over produced no change on screen until you clicked away and back.

The bar carried a pulsing LIVE indicator throughout. It was reporting on the queue, and an operator reading the transcript underneath it had no reason to think it meant anything else.

  • Fixed the conversation being a snapshot. It now subscribes to the same live updates the customer's own ticket page and the CRM admin screen have always used — the platform had been broadcasting every one of those events the whole time and this screen was not listening.
  • Anything typed in the composer survives an update arriving, so a reply being written is not lost when the customer types at the same moment.

Two operators could answer the same escalated conversation

Every row in the inbox queue records which operator is handling it, and nothing on the screen drew it. Conversations waiting for a person sort to the top of the queue for everybody, so two operators opening the highest-priority escalation is the ordinary case rather than an unlucky one — and both could take it over and answer, leaving the customer with two "a member of the team has joined" notices and two different replies.

  • Fixed the queue and the conversation both staying silent about custody. A row now says when somebody holds it and whether that somebody is you, and the conversation names them — with the takeover control changing to Take over anyway rather than reading as though the conversation were free.
  • Nothing is blocked. An operator can still take a conversation from a colleague, which is frequently correct; it is now a decision rather than an accident.

Your support queue may be full of empty live chats, and they can be deleted

Opening the chat bubble called the endpoint that fetches a live-chat conversation, and that endpoint had only one mode: fetch or create. So every visitor who clicked the bubble to see what it was, read the greeting and closed it again left a Live Chat ticket in your desk queue with no messages in it, waiting on you.

  • Fixed the widget creating a conversation on open. It now looks for an existing one without making one, and only creates when the customer actually sends something.

Existing empty rows are safe to remove. In CRM → Support → All tickets they are the Live Chat tickets with no messages, and deleting them costs you nothing — a customer who comes back starts a new conversation either way. Your ticket counts and your average-response figure will both change once they are gone, and both will be closer to the truth.

The support list was counting only the first ten tickets

The customer's ticket list asked for their tickets without specifying how many, and the platform returns ten by default. Every figure on that page was then derived from those ten: the total, the open and resolved counts, the average response time, the search box, both filters and the pager — which cheerfully reported "Showing 1 to 10 of 10 tickets" to someone with forty.

  • Fixed the counts, which are now taken from the database across the whole account rather than from whichever page happened to be loaded.
  • Changed the search to state plainly when an account holds more conversations than the page has loaded, instead of quietly searching a subset.

Customers with more than ten tickets will see larger, correct numbers after updating.

Three figures on the support screens were never measured

  • Removed a "+12% this month" trend from the customer's ticket total. No such figure was ever calculated; it was a fixed number written into the page and shown to every customer on every install.
  • Removed a 4.9 rating and a 2.5k resolved count displayed beneath any assigned agent's name, alongside "Online • Expert Level". None of the three came from anywhere — they were written into the markup as facts about the person your customer was talking to.
  • Changed an average response time of 0 and a satisfaction rating of 0.0/5 to an em dash where nothing has been recorded yet. A desk that has never answered a ticket has no average, and printing that absence as zero read as a boast in one case and a one-star review in the other — on the support desk's own page.

Both support queues were showing you part of the desk without saying so

Each console fetched a fixed number of conversations and then ordered them in the browser, so the database was choosing the rows and the ordering only rearranged that choice. The AI Live Inbox took the fifty oldest conversations, which means a new escalation past the fiftieth was absent rather than low down. The support desk took the sixty most recently touched, and its ordering puts the longest wait first — so the customer who had waited longest was the first row it discarded. The tab counts and the search box were computed from the same slice, so both described that slice rather than your desk.

  • Fixed the ordering on both queues, which now happens in the database before any limit is applied. The first row is the first row.
  • Added a sort control and a pager to each, with the real total for the tab you are in. Sort, direction and rows-per-page are remembered per browser.
  • Changed the search box and the tab counts to run across the whole desk rather than the page that happened to be loaded.
  • Added a Closed tab to the AI Live Inbox. Resolved conversations were unreachable there, and reopening a closed ticket returned it to the desk queue while leaving it hidden in the add-on's for good.
  • Changed the AI Live Inbox's default order so a conversation a colleague is already handling sits second: below those still waiting for a person, above those where the customer has yet to reply.

Updating fills in two new fields on every existing ticket, derived from the conversation each one already holds. Nothing is sent, no status changes, and the tickets' own timestamps are deliberately left untouched — otherwise a queue sorted by recency would report that your entire desk had been worked on the day you updated.

Your support queue is now a console, and the ticket table moved one click away

CRM → Support is no longer a table of tickets. It is a three-pane console — queue, conversation, ticket — that an operator answers from without leaving the screen. The address is unchanged, so bookmarks and the menu entry still work, but what opens there is a different screen.

Everything a console cannot do went with the table rather than being dropped: selecting several tickets and closing, reopening, assigning or deleting them; filtering and sorting on any column; searching every ticket ever raised; and the analytics tabs. They are at CRM → Support → All tickets, linked from All tickets in the console's top bar.

  • Changed /admin/crm/support into the desk console.
  • Changed the ticket table's address to /admin/crm/support/tickets, with its statistic tiles, its analytics and its bulk actions intact.

If your desk works in bulk — closing a batch of resolved live chats, assigning a morning's tickets to one agent — that is the screen to bookmark. A single ticket's own address (/admin/crm/support/<id>) is unchanged, and the links in your desk notifications still open it.

The support centre is now one page, and /support/ticket redirects to it

  • Changed /support to hold the whole support centre. /support/ticket answers with a permanent redirect, so existing links, bookmarks and the sign-in bounce all continue to work.
  • Removed the separate navigation bar the support section replaced the platform's own with. Its first entry duplicated the Support Center link the main menu already carries, and because one path was a prefix of the other both were highlighted at the same time on every ticket screen.

One conversation is still at /support/ticket/<id> and is unaffected.

If your platform is showing every visitor a region notice, one command undoes it

With geographic restrictions enabled and Block when the country cannot be determined switched on, an install that has no way to determine a country — no CDN sending a country header, no IP geolocation provider configured, or a reverse proxy hiding every visitor's real address — refuses every request from everywhere. Not the countries you restricted: all of them, yours included. Your country rules are never consulted, so nothing you change on the restrictions page affects it, and the refusal is rendered as your compliance notice rather than as an error, so the symptom looks like the feature working.

  • Added a repair command that reads the policy straight from the database. It needs no sign-in, no reachable admin panel and no running backend, which is the point — those are the things that are unavailable when you need it.

Run this from the backend directory:

pnpm geo:doctor
  • It changes nothing. It reports what is refusing traffic, what has been refused in the last 24 hours, and whether a single request in that window managed to resolve to a country. Run it first — it names every change --fix would make before you make it.
  • pnpm geo:doctor:fix applies the repair. Enforcement stays on and every country rule is left exactly as you wrote it. It switches off blocking for undetermined countries; it restores the administrator exemption and the customer wind-down carve-out if you had turned those off, so the next mistake is survivable; and where the outage is caused by allowlist mode with nothing permitted, it returns you to blocklist mode, where your BLOCK rules go on working and the countries you never listed are served again.
  • node scripts/geo-doctor.mjs --allow-ip <your address> --fix also adds your address to the always-allowed list, which is checked ahead of every country rule and is the intended escape hatch.
  • pnpm geo:doctor:disable switches geographic restrictions off entirely, if you would rather start again.
  • No restart is needed, and the command is the only thing here that is safe to trust. Changing these rows by hand in a SQL client does not work: the running platform holds its own copy of every setting, backed by a shared cache that never expires, so it goes on serving the old policy — and restarting reloads that same stale copy rather than your edit. This is the trap that turns a five-minute repair into an afternoon, because every change you make appears to be ignored and sends you looking for a second fault that does not exist. The command clears both copies and tells every running process to reload, so the repair is live within a second or two. If it cannot reach the cache it says so and prints the two commands needed to finish by hand, rather than reporting a success that has not happened.

The platform may now switch off one restriction setting on its own

The check above stops you saving a policy that refuses everyone. It cannot stop a policy that was safe when you saved it from becoming that later, and the ways that happens are ordinary: a Cloudflare plan lapses so the country header stops arriving, a lookup provider's key is rotated, a redeployment drops TRUST_PROXY from the environment. Detection stops working, every visitor becomes unplaceable, and nobody is watching the settings screen at the time.

  • Added a safety cut-out. When the engine has been refusing traffic while never once managing to determine a country — at least fifty decisions, at least two minutes after start-up, and not one country resolved among them — it switches Block when the country cannot be determined off, writes that to your settings, and records it in the log at error level.
  • Nothing else is touched. Not the master switch, not the policy mode, not one country rule. Your compliance position stays exactly as you set it; only the strictness preference that turned it into an outage is reverted.
  • The bar is deliberately absolute rather than proportional. If even one visitor anywhere was placed, detection works, every refusal since was your rule doing its job, and the cut-out stays shut.
  • Saving the policy yourself releases it. If you fix detection and want the setting back on, turn it on and save — that supersedes the override.

Two geographic restriction settings can no longer be saved

  • Changed: saving the restriction policy now checks what that policy would do before writing it, against your live rules, your detection health and your own connection.
  • Two configurations refuse one hundred percent of requests as a matter of arithmetic rather than judgement — blocking unknown countries with no way to determine one, and allowlist mode with no permitted countries. Neither can be saved, and there is no override, because in both cases the country rules are never reached and nothing anyone wanted is achieved.
  • Everything else is a warning you confirm past, including one that evaluates the policy against your own address and tells you when you are about to lock yourself out specifically. Real compliance obligations exist and a Super Admin can still meet them.

Your platform and your add-ons may be further behind than the update screens told you

When the platform asked the licence server whether an add-on had a newer release and the question failed — the licence server unreachable, a DNS hiccup, a timeout — it answered "You have the latest version of the product." That is the same sentence it uses when an add-on genuinely is current, word for word, so there was no way to tell a real answer from a failed one. The screen went green either way.

System → Update had the same fault for the platform itself, and one more case besides: an install with no purchase code on it at all was also reported as current, because that answer takes the same shape.

  • Changed: a check that could not be completed now says so, and names the reason. Only a real answer from the licence server produces "you have the latest version".
  • Changed the platform's own update screen to tell those states apart, and to report the time it last managed to ask.
  • What to do: open System → Update and Add-ons & Integrations and press Check for Updates once on each. If either now reports that the check could not be completed, the version it has been showing you was never confirmed by anything, and you should treat this install as being of unknown currency until it is.

The update-check endpoint was answering callers who were not signed in

The route that reports whether an add-on has a newer release was missing its authentication requirement. It carried a permission, but the platform only enforces a permission on routes that also demand a signed-in caller, so the permission was decorative and the route answered anyone who asked.

  • Changed: it now requires a signed-in operator holding the same licence permission as applying an update.
  • What was exposed: which add-ons are licensed on your install, the version each one is on, and the release notes for a pending version. An anonymous caller could also make your server call out to the licence server on demand, which the rate limiter throttled.
  • What was not: no purchase code, licence key, credential or customer data was ever returned by this route. It does read your licence file to ask its question, but it does not include it in the answer.

One press updates every add-on, and the backend then needs restarting

  • Changed: Add-ons & Integrations has an Update All button. It re-asks the licence server directly rather than trusting the counts on screen, queues every licensed add-on that is behind, and takes each one through its pending releases in order until nothing is left.

Add-ons that are switched on without a licence are now named, and you may find some

Add-ons & Integrations now opens with a warning when any add-on is switched on while its licence cannot be verified. Nothing on that screen said a word about this condition before, and it is worth understanding before you see it, because the platform does not stop such an add-on from running: its routes keep serving your users while every licensed call inside it fails. The switch on that add-on's own row is disabled until a licence verifies, so it could not be turned off from the list either.

It is reachable by ordinary accident rather than tampering. A restored database, a copied install, or a backend started from a directory where the licence folder does not resolve will flip every add-on to unlicensed while every one of them stays switched on — which is also why the warning is loud when it fires. In that case the add-ons list will show it against everything you had enabled at once.

  • Added: the warning names up to three affected add-ons and links each one straight to its activation screen.
  • If you see it, check whether your licence folder is present and readable by the backend before re-entering any purchase codes. A missing folder explains every entry at once; a genuinely lapsed licence explains one.

Two-factor, wallet keys and token signing are deliberately unchanged

This is the part of a dependency release that can hurt customers, so it is stated plainly: none of it moved.

  • Changed: nothing. The one-time-password library, the money-arithmetic library, the Redis client and the job queue are all held at the versions you are already running.
  • Why: the one-time-password library's next major does not export the function this platform calls at all, and codes generated from an already-enrolled secret stop validating against it. Updating it would mean every customer with two-factor enabled re-enrolling, and anyone who did not would be locked out.

Optional date fields could not actually be left empty

Every admin form that offers a date you are told you may skip — the geographic restriction's Effective from and Effective until, an NFT listing's start and end time, an NFT offer's expiry — refused to save while that date was blank. Pressing Save marked the empty field "Invalid date" directly beneath the sentence inviting you to leave it empty, and there was no way to satisfy it other than inventing a date.

  • Changed: a blank optional date saves. It reaches the platform as "no date", which is the same thing as never having set one, and clearing a date that was already stored now genuinely clears it rather than being rejected.

Every admin and add-on page heading looks different, and a customised fork will not build

The change is deliberate and it is not subtle — you will see it on the first screen you open. Headings lose their coloured glows, their drifting particles and the gradient through the title; they gain a shared background, and the block of statistic cards some of them carried becomes a single row of figures.

  • Removed the settings that let a page choose its own heading colours and animation. They are gone from the platform's own vocabulary, not merely ignored, so a fork that still passes one fails to build with an error naming the setting rather than quietly rendering something else. That is the intended outcome: a value that is silently discarded is how the old palette survived two migrations.
  • Nothing is required of a stock installation. If you have customised these pages, the repair is to delete the colour and animation settings from your heading — everything else about the heading is unchanged and still yours to set.

Component proportions are settings now, and a customised component can pin one dead

The controls described above work by having each component read its height, padding, weight and corners from the theme instead of carrying them literally. Nothing about a stock installation changes — every setting ships at the value it replaced — but the components themselves were edited to make it possible: the admin table's rows, header and cells, and Button, Card, Input, Badge, Label, Dialog and the metric tile.

  • Changed: three hardcoded styles were removed from the admin table rather than overridden — the row hover tint, the header label weight and a redundant cell border. Each of those wins against a theme setting by the rules of the styling toolchain, so leaving them in place would have left the matching control moving on screen and doing nothing.
  • If you have forked any of those components and written a height, padding, weight or corner back into it, the corresponding control in Site Design → Components will have no effect. The slider will move, the preview will not, and nothing will report an error — a setting that is silently outranked looks exactly like a setting that is broken. The repair is to remove the hardcoded value, not to add another.
  • The same thing is true of the platform's own screens, and it is measured: 1,050 places across 331 files ask a button, an input or a card section for a specific height or padding at the point they use it, and that instruction replaces the setting rather than sitting alongside it. Control height, Control padding and Card padding therefore move a large part of the platform and leave those places where they are. 120 hardcoded values are removed in this release — the ones asking for exactly the size the component was already going to draw, so nothing about them changes on screen and they now follow the control. The rest are stated rather than quietly repaired, because naming a size instead of a height also changes that size's padding and its label size: each one is a small design decision, not a substitution. They are counted, listed and held at today's number, so the total can only fall.
  • Stock installations need do nothing, and the settings are optional: a platform where nobody opens that group renders exactly as it did before.

The palette reset says what it clears

Reset colours to shipped, in the Site Design overflow menu, also cleared the corner radius, the typefaces and the motion curves. Only one of the four was named.

  • Changed the menu item to Reset palette to shipped, which is what it has always done. It leaves the new component proportions alone — each component panel carries its own reset — so clearing a palette no longer clears a density you set separately.

Fifteen packages leave the installation, which can affect a fork

  • Changed: fifteen declared-but-never-imported packages are gone, including a prerelease build of a data-fetching library that nothing used. Every one was verified unreachable — not imported, not lazily imported, not referenced from a stylesheet, a template, or any build or lint configuration.

Your profit report may show a lower — and correct — net figure

Losses the platform absorbed while its treasury wallet was empty were being silently dropped instead of recorded. The figure they were missing from is your net profit, so it has been reading high on any install where that happened.

  • Fixed: the loss is now written even when there was nothing to debit, and the platform log names the amount and the reference so you can find it. Previously the record was rejected before it was saved and the rejection was swallowed as a warning, which meant the one case the feature exists for — a loss taken with no funds to cover it — was the exact case it never recorded.
  • What to check: nothing needs doing. Finance → Profit may simply show a smaller net after this update, and the difference is losses that were always real. Binary payouts, investment outcomes, affiliate reward claims and forex, AI, staking and gateway settlements can all take a loss this way.
  • Why it went unnoticed: an install whose treasury is funded never hits it. It only bites when the treasury is empty, which is also when nobody is looking at it closely.

Referral payouts on deposits will fall, and they were wrong before

Two conditions describe the same one-time event — Welcome Deposit Bonus ("a welcome bonus for the first deposit of at least 100 USDT") and First Deposit Reward ("commission earned when a referred user makes their first deposit") — and neither checked whether it was a first deposit.

Welcome Deposit Bonus was granted on every credited deposit. First Deposit Reward was left to the periodic evaluator, which pays against summed volume per period, so it recurred for as long as a referred user kept depositing. A referrer therefore collected both on a referred user's tenth deposit exactly as on their first.

  • Fixed both so they are granted once, on a referred user's first completed deposit, and never again. Rewards already accrued are untouched.
  • What to check: if you advertise a referral rate, compare it against what has actually been paid. Finance → Affiliate → Rewards will accrue more slowly after updating, and the earlier figure was the error.

Fixed volume bonuses were paying every day instead of every month

Five referral conditions pay a flat amount for reaching a volume threshold — Spot Trading Volume Bonus, Monthly Trade Volume Bonus, Binary Options Winning Trade Bonus, Binary Options Monthly Volume and Futures Volume Bonus. None of them recorded which period they measure, and the platform's default is daily.

So a bonus whose own description reads "more than 1,000 USDT in a month" paid its flat 50 for every day the threshold was met. There was no way to correct it from the admin screen either — the period is not a field you can edit.

  • Changed all five to monthly, which is what their titles and descriptions have always said.
  • This applies to new installations. An existing install keeps the rows it already has, because the seeder never overwrites a condition you may have tuned.
  • What to do: if any of those five are switched on, open Admin → Affiliate → Conditions and confirm the amounts are what you intend. They have been accruing at roughly thirty times the advertised rate.

Several active referral conditions can pay on the same activity

The platform now warns about this while seeding, and the warning is worth acting on. Conditions are matched to activity by type, several distinct conditions share one type, and the periodic evaluator pays every active one rather than choosing between them. A single deposit can therefore satisfy three conditions at once.

  • What to do: read the warning printed during the update, then open Admin → Affiliate → Conditions. Leave one active condition per activity unless you genuinely intend them to stack. Nothing is switched off for you — which of them you want is a commercial decision.

A peer-to-peer release that would pay the buyer nothing is now refused

If the escrow behind a trade was not actually held, releasing it completed the trade and credited the buyer zero — after they had already paid the seller off-platform. The trade then closed permanently, because a settled escrow cannot be reopened.

  • Fixed the release action to refuse in that case and leave the trade exactly as it was, so it can be taken to dispute with the position still recoverable.
  • Added pnpm p2p:doctor, which reports every trade claiming a hold and sorts them into the ones that are safe to clear and the ones whose funds are real but out of reach. It moves no money and reports by default; --apply clears only the provably empty ones.
  • What to check: run pnpm p2p:doctor from the backend directory. If it reports anything under B or C, those trades hold real funds and must not be cleared — the output names the repair for each.

Trades that could never expire no longer retry for ever

Settlement refused to act on a trade whose seller wallet it could not find, and the timeout job retried the same trades every sixty seconds indefinitely — an error per trade per minute, and the job permanently marked as failed.

Two things caused it. Settlement assumed a spot wallet whenever it could not read the trade's own offer, so a hold taken in an ecosystem or fiat wallet was looked for in the wrong place. And on installs that predate the escrow authority, pnpm p2p:reconcile marked every in-flight trade as holding funds based on its status alone, without checking that any wallet held anything.

  • Fixed settlement so it never assumes a wallet type, and so a trade with genuinely nothing held is closed out instead of retried — but only after confirming the funds are not merely soft-deleted or held under another wallet type, either of which is recoverable and must not be written off.
  • Changed pnpm p2p:reconcile so it no longer marks a trade as holding funds unless a wallet actually holds them. It reports those trades instead and leaves them untouched.

Open orders placed on a previous exchange are no longer retried against the current one

Order records store the id the exchange issued, but not which exchange issued it, and reconciliation always asks whichever provider is active now. After switching providers, every still-open order from the old venue was offered to the new one, which rejected an id it never minted — once a minute, for ever.

  • Changed new orders to record the exchange that placed them, so reconciliation skips the ones that are not its own.
  • Changed reconciliation to stop retrying an order the active exchange rejects as unrecognisable, and to say so once rather than every minute. The order is left open for you to close or reconcile against the venue that placed it.

An offer's currency, wallet or direction can no longer be changed while it holds escrow

Admin → P2P → Offers allowed an offer's type, currency and wallet type to be edited at any time, including while the offer was holding funds and had trades in flight. Those three fields are what settlement uses to find the escrow, and the escrow was locked against whatever they said when the hold was taken — so changing one sent settlement looking in a wallet that never held the money. The trade could then never complete, and the real hold was left with nothing able to release it.

  • Changed those three fields to be refused while the offer holds escrow or has a trade in PENDING, PAYMENT_SENT or DISPUTED. Everything else on that form stays editable, on a live offer as before.
  • What to do: settle or cancel the open trades first, or create a new offer. If you have edited one of those fields on a live offer in the past, pnpm p2p:doctor will report the affected trades under C — their funds are real and recoverable, and the report names the wallet actually holding them.

Added

A choice of captcha, and four checks on every token

  • Added Captcha Provider to Settings → Security → Protection: Cloudflare Turnstile, Google reCAPTCHA v3, hCaptcha, the built-in Proof of Work, or none. Turnstile is the recommended choice — free, unlimited, and invisible to nearly every real visitor.
  • Added Site Key and Secret Key, shown only when a hosted provider is selected. The secret is withheld from the platform's public settings feed, which is unauthenticated and otherwise returns the settings table nearly whole, and changing either it or the provider requires a Super Admin.
  • Added a reCAPTCHA Score Threshold control, because reCAPTCHA v3 never fails a request — it grades it from 0.0 to 1.0, and code that reads only "did it succeed" treats a bot scoring 0.1 as a human. The threshold is the entire control; 0.5 is Google's own starting point.
  • Added a hostname check on every token. A token solved on an attacker's own page against their own site key is a valid token, and without this check it is also a valid token here — the most commonly skipped step in a captcha integration.
  • Added an action check, so a token farmed from one form cannot be spent on another. Turnstile and reCAPTCHA report this; hCaptcha does not, and the page says so.
  • Added a refusal when the browser claims to have solved a different provider from the one the platform has armed, so a caller cannot pick the cheapest one.
  • Added an error-level log line every time an action is allowed through without a captcha check. A control that has quietly stopped running is now a line in your log rather than something inferred months later from a table full of junk accounts.

A per-address budget on the routes that create accounts and sessions

  • Added a signup budget — three accounts per address per hour, fail-closed — on POST /api/auth/register and on Google signup, which had no captcha and no limit of its own.
  • Added a login budget of thirty attempts per address per fifteen minutes, fail-open, alongside the unchanged per-account lockout.
  • Added a captcha to Google signup. Holding a Google token is not evidence of humanity: it is minted against the platform's own public client id and scripting the flow is a solved problem.

A page per investment, with the clock on it

An investment is a promise with a date on it, and no screen in the product showed the date running. Every record carries the day it opened and the day it matures; the dashboard rendered neither, so the only way to find out when your money was coming back was to keep checking.

  • Added /investment/<id>: the principal, a progress bar across the term, how long is left, the exact maturity date, the plan's settlement rule, and what the position will return.
  • Added a Cancel investment action to that page — the first caller of a refund route that has existed unused since the product shipped. See Upgrade Notes.
  • Added an awaiting settlement state for a position whose term has ended but which the settlement run has not reached yet. It is a real state — the run happens on a schedule, not on the stroke of the maturity date — and describing it as still running was the alternative.

My investments, in place of a dashboard and a history table

  • Added /investment/portfolio, replacing both. Running positions come first, sorted by how close each is to paying; everything settled sits below. See Upgrade Notes for the redirects.
  • Added per-currency totals: capital at work, realised gain or loss, and how many positions are running out of how many held — one row per currency, never summed together.
  • Added a count of any positions that could not be included in those totals because their plan has since been deleted, rather than filing them under a currency they do not have.

Every plan states what it pays and what it settles as

  • Added the settlement rule beside the rate on the plans list, the plan page and every position — Pays the rate, Returns principal or Deducts the rate, each with its own icon and word rather than a colour alone.
  • Added a signed projection to the invest form: what the chosen amount does to the principal at maturity, and what returns to the wallet. Where a plan does not state a rule the projection is withheld rather than assumed to be a gain.
  • Added the plan's terms, currency and funding wallet to the plan page, and a note that the term sets the maturity date and not the amount — a plan's percentage is applied once whichever term is chosen, and a menu of terms invites the opposite assumption.

The reasons an investment would be refused, before the button is pressed

The platform rejects an investment for five separate reasons and the form checked one of them. The rest were discovered by pressing the button and reading the refusal.

  • Added up-front notice that a plan has no terms configured and therefore cannot be opened at all.
  • Added up-front notice that the customer already holds a running position in that plan, with a link to it. The platform permits one at a time, and the only way to learn that was to fill the form in and submit it.
  • Changed the amount limits to be stated as the platform actually applies them — a limit set to zero is not enforced, so it is no longer claimed.

One support centre, organised by whose turn it is

The list a customer opens is a queue with one question attached to every row, and the status column already answered it — a ticket is either waiting on the desk or waiting on the customer. Nothing surfaced that. It offered a status dropdown instead, which asked the customer to know that "Replied" meant "your move".

  • Added whose-turn grouping to the support centre: waiting on us, waiting on you and resolved, each carrying a live count taken from the database. The one bucket that needs the customer to act is marked as such on the row as well.
  • Added the last message and its author to every row, so a conversation says where it is rather than only what it was about when it opened.
  • Added a counters endpoint for a customer's own tickets, counted in SQL. It reports no average and no satisfaction score where nothing has been recorded, rather than reporting zero.

Raising a ticket is a page

The form asks for a subject, a priority, tags and the description of the problem — the longest thing most customers will ever write on the platform — and it did all that inside a 500-pixel modal with a 120-pixel text box. A modal cannot be linked to, so every "contact support" prompt elsewhere had to send people to a list and hope they found the button; browser-back discarded whatever had been typed; and on a phone with the keyboard open roughly one line of the description was visible.

  • Added /support/new as a real page, with room for the description, guidance on what to include, and the option to start a chat instead.
  • Changed priority from a dropdown to three choices that each say what they mean. A collapsed list shows one option and hides the consequences of the other two, which is part of why self-declared priorities converge on "High".

The sources an answer was drawn from, where a customer can read them

  • Added a resolution panel to the customer's conversation, replacing the column of ticket facts. It carries the help articles an answer cited — each with the passage the answer actually rested on, which until now existed only as a tooltip and was therefore unreachable on any touch screen — the destinations the answer offered, and the ticket's own details demoted to a third tab.
  • Added a single reference marker under a message that carries sources or actions, in place of up to eight separate chips and pills beneath it. It opens the panel on the matching cards, so a citation is read beside the answer rather than in a new tab that loses the conversation.
  • The panel is only as large as it has something to hold: a ticket with no cited sources and no suggested actions shows no tabs at all, and says what will collect there rather than drawing an empty column.

A customer can rate a resolved conversation

  • Added the satisfaction rating as a question on a closed ticket. The platform has stored a rating per ticket since support shipped and every screen could display one; the route that records it existed and no screen anywhere had ever called it, so on most installations the stars were decoration for a value nothing collected.
  • It is asked once. A ticket that already carries a rating shows it instead, which is the rule the route itself has always enforced.

The operator inbox can finish a conversation where it is worked

  • Added status, priority, assignment and close/reopen to the AI support inbox, beside the conversation they apply to. Each of these already existed on the CRM ticket screen, so resolving a conversation here meant solving the problem on one screen and recording it on another.
  • Added a priority control at all. Ticket priority was rendered as a badge on the CRM ticket screen and there was no screen in the platform that could change it — it could be set when a customer raised the ticket and never afterwards.
  • Added a Teach from this ticket action to the same panel, shown only to operators who may create knowledge articles. It rendered for everyone before and failed for anyone without that permission.

What the assistant established, before you read the transcript

  • Added a brief panel to the operator inbox carrying why the conversation escalated in plain words, what the assistant had established, and the reply it suggested. The escalation reason was already being fetched for every row and every conversation, and was printed nowhere on the operator's side and raw where it did appear.
  • Added the custody history — every time the conversation changed hands, with the reason and any note. This was also already being fetched on every selection and drawn nowhere, and it is what an operator needs when a conversation has been passed twice and the second person is about to repeat the first.
  • Added an evidence panel: the retrieval and grounding scores as adjacent bars, and the passages the assistant read, each framed as a quote rather than printed as unlabelled grey text.

A Mine filter, and what a conversation escalated for

  • Added a Mine bucket to the inbox queue, alongside waiting, assistant-handled and all — the one an operator works from, filtering on a field every row already carried.
  • Added the escalation reason to each queue row in plain language, so a column of identical "waiting for a person" chips becomes a list you can choose from. Searching now matches it too, which is how you find every conversation that escalated for the same undocumented policy.

The support desk is a console, not a table of tickets

The screen an operator opens to work the desk could not be worked from. It listed tickets and linked out to them, so every answer cost two page loads and lost the operator's place in the queue twice.

  • Added a three-pane desk console at CRM → Support: the queue, the conversation and the ticket's own controls in one viewport, with no site chrome above them. Replying, setting status and priority, assigning, closing and reopening all happen there.
  • Added five queue filters — Waiting, Mine, Free, All and Closed — a search box over the queue, and J/K to move through it. The first four carry a live count; Closed does not, because that set is fetched only when you ask for it and any number beside it would be a guess. Free is the unassigned work nobody has picked up; Waiting is the default, because it is the only one with a customer on the other end of it.
  • Added the last message, its author, the message count and the wait so far to every queue row, so a row says where the conversation is rather than only what it was about when it opened. A row somebody else is already answering says so by name.
  • Added a live connection on every conversation in the console. The ticket screen only ever held one for live chats, so a customer's reply to an ordinary ticket did not appear there until the agent reloaded the page.
  • Added the customer's history — tickets raised and tickets resolved — to the column beside the queue, so the context that decides whether this is a first question or the fourth time somebody has asked it is on the screen the operator is working in, not only on the ticket's own page.
  • Added a shareable address for the open conversation, so an operator can send a colleague the exact conversation they are looking at.

A queue ordered by who has been waiting longest

  • Added an endpoint that orders the desk by the age of the last message rather than the age of the ticket, and reports for each conversation whether the customer or the desk spoke last. The old table could only sort by a column, and the column it used — when the ticket was opened — put a year-old ticket answered yesterday above one raised this morning and unanswered since.
  • Answered conversations are demoted rather than filtered out, so an operator can still find the reply they sent a minute ago.

The AI Support add-on, where a support operator would look for it

  • Added a block at the foot of the desk queue that links straight to the AI Support add-on's Live Inbox when it is installed, and describes what the add-on does when it is not. It is hidden entirely from roles that cannot open the add-ons screen, rather than offering a support agent a link they cannot follow.

A light/dark switch on the two full-screen support screens

  • Added a theme control to the customer's conversation screen and to the AI support inbox. Both deliberately replace the platform's navigation bar with a compact one to give the conversation the whole viewport — and the theme switch lives in the navigation bar, so on the two screens somebody may sit in for an hour there was no way to leave dark mode without navigating out of the screen entirely.

A conversation is a full-screen application

  • Added a full-viewport conversation screen with the site navigation suppressed, a compact bar carrying the subject and status, and a details rail. It previously spent roughly a third of a laptop screen on page furniture — header clearance, a heading repeating the subject already on screen, and padding — before pinning the messages themselves to a fixed-height box inside what was left.
  • The bar carries a back arrow, because removing the navigation removes every other way out of the screen.

The restrictions screen now tells you whether country detection actually works

Until now the Detection tab could only show you what you had chosen. Whether any of it functions is a different question, and one no setting can answer — so an operator had no way to tell a working install from one where every visitor was unplaceable. Both looked correctly configured.

  • Added a live detection status to the geographic restrictions settings screen, taken from real decisions rather than from configuration. It states plainly when no request has ever resolved to a country, names which source is working when one is, and warns when requests are arriving through a reverse proxy while TRUST_PROXY is off — which means the country being determined belongs to your proxy rather than to your visitors.

A preview of what a restriction policy will do before you save it

  • Added a confirmation step that runs the proposed policy through the real decision engine and lists what it would do to real people: which visitors lose access, whether existing customers can still reach their balances, whether your own connection would be refused, and what to do instead in each case. Warnings that were previously invisible — a change that is serious but legitimate, and therefore saves without complaint — now arrive at the one moment they could change your mind.
  • The same check runs on the server when the policy is written, so it also applies to anything that saves the policy without going through the admin screen. A confirmation dialog is advice; this is the part that cannot be skipped.

A repair command for a geographic lockout

  • Added pnpm geo:doctor, which diagnoses and repairs a restriction policy directly from the database when the admin panel is the thing being blocked. See Upgrade Notes for what it reports and the three ways to run it.

One-press updates for every licensed add-on

  • Added an Update All button to Add-ons & Integrations. It brings every licensed add-on to its newest release in a single press, and shows a live queue while it works: each add-on's version as it moves, which release is downloading, and what failed if anything did.
  • Added a forced re-check as the first thing the run does. The update counts shown on that page come from a ten-minute cache, so "0 updates available" there routinely means "nobody has asked recently" — the button therefore appears whenever anything is licensed, not only when the page has already noticed something.

The platform's update screen says when it last asked, and what it found

  • Added a Last checked time to System → Update. Update flags reach that screen through a ten-minute cache, so "up to date" there regularly means "nobody has asked recently", and the screen had never said which.
  • Added a state for a check that could not be completed, carrying the licence server's own reason — see Upgrade Notes. There is a fourth for the moments before the first check has returned, which had been drawn as the all-clear.
  • Added the platform's Product ID to that screen, beside the support and documentation links. It is the first identifier support asks for and the screen had never shown it.
  • Added a route to the full activation page for an operator holding a licence file rather than a code. The form on this screen takes a purchase code only, so a managed or air-gapped install had reached a dead end.

A warning for add-ons running without a verified licence

  • Added a notice at the top of Add-ons & Integrations listing add-ons that are switched on while their licence cannot be verified. See Upgrade Notes: those add-ons keep serving your users while everything licensed inside them fails, and their own row cannot switch them off.

The add-ons list says when an update badge cannot be acted on

  • Added a line explaining the difference between the add-ons carrying an update badge and the number on Update All. An add-on that is not licensed still gets flagged when a release exists, but there is nothing to download for it — so the badge appears on cards the button will skip, and until now nothing said why.

An add-on's page now says where the add-on lives

  • Added a map of the pages an add-on installs to its Overview tab, naming each page and the address it sits at, admin and customer-facing alike. The licensed view had listed admin pages only, by label, with the address hidden — on a screen whose whole subject is a product that exists to add pages to your platform.
  • Added the add-on's release state and its identity beside that map: the version you are on, the version being offered, the date of the newest published notes, and the last three releases.
  • Added a stated empty result for an add-on that publishes no page map, rather than dropping the section silently and leaving you unable to tell that case from an add-on with no pages.

Pictures and descriptions for the four add-ons that shipped without them

  • Added product illustrations for Binance US, Kraken, OKX and MO Chain. Every other exchange provider and blockchain in the catalogue had one; these four are bundled rather than sold, so nothing had ever drawn theirs, and they rendered as a bare category icon in a grid of full illustrations.
  • Added the feature, benefit and page-map copy behind all four, so opening one now describes the product instead of showing a single line saying it needs a licence.
  • Added a description for the three exchange providers that had none stored. Their cards had been reading "No description available" since the day they appeared.

Component proportions are settings

  • Added a Components group to Site Design, carrying 27 controls across five panels — Tables, Buttons & inputs, Cards, Forms & dialogs, Charts & metrics. They govern proportion rather than colour: row height, cell padding, text size, header weight, striping, row rules and hover strength; control height, padding, corners, label weight and border width; card padding, border and elevation; field spacing, label weight, dialog padding and scrim strength; chart series width, gridlines, data-point size and metric-tile proportions.
  • Added 23 named starting points across those panels — Compact, Roomy, Striped and Ruled tables; Rounded, Square, Heavy and Compact controls; Flat, Outlined, Roomy and Tight cards; Dense, Airy and Emphatic labels for forms; Fine, Bold and No grid charts — each panel also carrying an "As shipped" that clears it. Applying one is a starting point, not a mode: the panel reports Custom as soon as you adjust anything.
  • Every control ships at the value it replaced, so a stock platform renders identically until something is moved. Corner settings multiply the radius chosen on the Presets tab rather than replacing it, so a squared platform keeps square controls.
  • Added a specimen page behind the Site Design preview, drawing every component the settings reach in every variant and state it ships in — a populated table with its heading, actions and selection; every button shape, tone and size; every badge, card, alert and form field; and each chart type. The preview shows the family you are editing, because the public pages it could previously load contain none of these.

Six more palettes

  • Added six presets to Site Design → Presets, taking the gallery to fourteen. Bullion is brass on near-black with barely-there corners; Nordic is light-first and low-contrast with a lot of air; Basalt has almost no colour anywhere, so structure carries the interface; Sakura is soft pink on warm white, fully rounded; Lagoon runs teal through the neutrals as well as the accent; Phosphor is green on true black with square corners and next to no easing.
  • Each was checked against the same Accessibility tab the design screen shows you, in both light and dark, including the separation between chart series for colour-blind readers. Five of them move a status hue so a signal does not collide with the brand — Phosphor's accent takes the usual success green, so success and rising prices shift to a cooler green and a winning trade is not drawn in the same colour as every button on the screen.
  • There is deliberately no red-accent palette. A red brand would land on the same hue as the destructive colour and on the one that means a falling price, which would put the loudest colour in the interface on both the thing you must not miss and the thing that is always on screen.

A shim for the brand icons the icon set dropped

  • Added components/ui/brand-icons.tsx, which draws X/Twitter, Facebook, LinkedIn, Instagram and GitHub through the platform's other icon library while keeping the call shape the old icon set used. The icon library removed every third-party brand mark in its new major version, so those five stopped existing; they are back in the same places, at the same sizes, in the same colours, with the glyph drawn a little differently — the affiliate dashboard and referral generator, the NFT collection and creator pages, ICO team profiles, and the social links on a customer's own profile.

The currency icon list can now reach past its first 250 rows

System → Currency Icons finds every currency with no icon and lists it worst first. It listed the first 250 and stopped — and not behind a next button: row 251 was unreachable from the screen entirely, with the command line the only way to see the rest. An install that has imported a full exchange catalogue routinely has thousands of symbols in that queue, so on the platforms where the screen matters most it was showing a fraction of one.

  • Added paging, at 25, 50, 100 or 200 rows to a page.
  • Added a search that matches a symbol, the name your catalogue holds for it, the resolver that can supply it and the reason it cannot be — so a currency a customer has just asked about can be found rather than scrolled to.
  • Added filters for asset class and for outcome, each carrying its own count, and a choice of order: worst outcome first, by symbol, by asset class or by resolver.
  • Added a coverage figure at the top of the page — how much of your catalogue can actually draw itself, as one percentage and one bar, with each asset class's shortfall drawn as part of that same whole rather than as four counts you have to add up.

Fetching one icon rather than two hundred

The report is deliberately offline: it must never depend on a third-party logo service being reachable, so it consults only the sources already on your disk. That is also why so many rows read Unresolved — it means nothing local matched, not that nothing anywhere has that logo. Acting on one of those rows meant running Fetch up to 200 icons and waiting for a batch.

  • Added a Fetch action on each row, which runs the full resolution chain — including the networked sources — for that one symbol. An Unresolved row is often the best candidate for it, which is the opposite of how the word reads.

A check that holds the platform's look together, and proves it can still see

The platform has a check whose job is to stop the interface drifting apart — one set of colours, one card, one set of controls. It reported everything clean. It was clean, on the ten things it looked at, and it had never opened a stylesheet, never seen a stacking order, never been run automatically, and had no way of noticing if one of its own rules stopped working. That last one is the expensive part: a rule that goes blind reports zero, and zero is what success looks like. It had happened twice, and both times it was found months later by someone re-reading the rule rather than by anything failing.

  • Added fourteen new checks, taking it from ten to twenty-four. Among them: text on a tinted chip that falls below the readability floor, a colour written into a stylesheet, a chip colour that does not exist, a shadow the Elevation control cannot reach, a stacking order invented rather than chosen, a corner that ignores the corner setting, and a stylesheet rule that quietly overrules the whole design system.
  • Added a self-test: every rule now carries examples of the fault it exists to catch and examples of the correct code it must leave alone, and it is run before the check reports anything. 182 assertions. Both historical blind spots were re-introduced deliberately to confirm it catches them.
  • Added the check to the run that happens before code is published, alongside three sibling checks that existed and were wired to nothing. One of those had been failing for some time and had never told anyone.
  • Added a per-site listing and a machine-readable output, so a failure names the file and the line rather than a number.

There is now a public register of the domains running a licensed deployment, and a visitor can look up any address in it. It exists for the customer who suspects the site in front of them is an imitation of yours: a copy can reproduce your pages, but it cannot put its own address in the register. Your domain is entered from your licence activation, so nothing is required of you to appear there.

The mark itself is a shortcut, not proof — anyone can draw a tick on a page. What cannot be copied is the register entry, which is why the mark leads there and why that entry states in full the domain it describes.

  • Added a Registry Verification Badge switch to Settings → General, off by default. Turned on, your public pages carry a small Registered mark in the footer beside your legal links, linking to your entry.
  • The mark appears only while your domain is registered, and draws nothing otherwise. An unregistered answer is usually something you cannot act on from the page it would appear on — a domain that has just moved, a register briefly unreachable — and a warning in your own footer would alarm your customers over it.
  • Your server makes no outbound request for this. The check runs in the visitor's browser and is cached, so an unreachable register costs your pages nothing.
  • The register is currently served from a MashDiv address, which a customer following the mark will see. Its pages name no software and no vendor, but the address does — if your platform is white-labelled, leave this switch off until the register moves to a neutral domain.

Changed

Bot protection

  • Changed the challenge endpoint to report which provider is armed and its public site key, so the sign-up form knows what to render rather than assuming proof-of-work. It keeps its old address, because that is the one every released client already calls.
  • Changed the browser's proof-of-work solver, which had been doing a third of its work for nothing: per rejected attempt it converted the hash to text and then expanded that into a 256-character string to count zeros in. An attacker's solver reads the first few bytes and stops. The tax landed entirely on real customers, and it is gone.
  • Changed the solver's time budget, which was a fixed thirty seconds. That is shorter than a high-difficulty solve on most phones, so the highest setting silently abandoned a large share of real signups before the request was sent. The budget now comes from the server's own expiry.
  • Changed the browser to tell "no captcha is configured" apart from "I could not ask". Those were the same value, so a failed request for the configuration submitted the form with no captcha at all — the one case where it mattered most.
  • Changed the PoW Difficulty description to say what raising it does. Both the customer and the attacker scale identically with difficulty, so the dial moves the cost for your visitors and leaves the ratio between them exactly where it was.
  • Changed the per-address cap on issuing proof-of-work challenges from ten a minute to thirty, and fixed the counter, which read-then-wrote instead of incrementing — so a batch of simultaneous requests advanced a ten-wide budget by one. Ten was tight enough that an office behind one address could be refused, and refusing a challenge refuses registration and login outright.
  • Changed the login and reset routes to stop flattening every captcha outcome into one message. A caller who failed the check and a secret key that was typed wrong are different events, and reporting both as "security verification failed" told an operator with a broken key that the caller was at fault.

Investment

  • Changed the two plan endpoints to send the plan's Default Result, and every customer-facing screen to state it beside the rate. See Upgrade Notes — this is the one that may change how a plan of yours reads.
  • Changed /investment/dashboard and /investment/history into /investment/portfolio, with redirects. See Upgrade Notes.
  • Changed the plan page to fetch the plan it was asked for rather than searching the list of open plans for it. The list is filtered to plans currently accepting investment, so a plan an operator had just closed answered "Plan not found" — as did any direct link opened before the list had arrived, which was every cold load of that address.
  • Changed the investment menu's Admin shortcut, which pointed at the AI Investment add-on's console. That is a different product with its own plans and its own settlement; an operator following it from here arrived somewhere whose edits do nothing to these plans. It now opens Finance → Investment → Plans.
  • Changed the money on every investment screen onto one formatter. Four copies had grown up with different rules, so the same plan minimum printed as $1,250.75 on one page and $1,251 on another, and all four decided fiat-or-crypto from a partial list of 50 codes — which sent real currencies such as AED, SAR and VND down the wrong path.
  • Changed the settled-investment list off the data table it used to be. Both money columns were declared as plain numbers, so amounts and results printed with no currency at all — a BTC position's 0.5 and an NGN position's 40,000 in one column with nothing to tell them apart — and the panel that would have shown the currency was switched off, so on a desktop there was no way to open a row and find out.
  • Changed the term shown against a position to be built by the translation system rather than by adding an English "s". The old form produced "3 days" in every language on the platform, and "1 day" only by luck.
  • Changed the customer's investment pages onto the platform's shared page background, navigation and footer, which they had never used.

Support centre

  • Changed /support and /support/ticket into one page, and the section back onto the platform's own navigation bar. See Upgrade Notes.
  • Changed the customer's ticket figures to be counted in the database rather than summed from the first page of results. See Upgrade Notes.
  • Changed the live-chat status line to describe the conversation instead of the connection. It read "Online" with a green dot whenever the browser held a websocket — a fact about the customer's own network, printed directly under the word "Support", where it reads as an agent being present. It now says whether a reply is being waited on, whether the desk has answered, or whether the assistant is handling it.
  • Changed customer messages to carry the language the customer is reading the site in. The platform has no record of a customer's language — it is a browser preference and there is no column for it — so anything answering on the desk's behalf had no way to know, and answered in English regardless of where the conversation was being read.
  • Changed the loading frames on all three support screens to match the page that replaces them, instead of a blank screen or, on one route, the wrong page's outline entirely.
  • Changed the column beside a conversation, on both the customer's screen and the operator's, from a list of facts about the ticket into the conversation's sources, actions and controls. See Highlights.
  • Changed the handover notice in a customer's transcript from a small centred pill into a full-width rule. On the ticket screen the handover is a turning point in the case — the moment the assistant stood down — and a grey pill floating mid-column reads as an aside.
  • Changed both conversation columns to one fixed width. They had been sized from their own contents, so a conversation carrying two cited sources was wider than one carrying none and the transcript beside it resized on every switch between tickets — which also meant the loading outline was drawing a frame that was about to be the wrong size.
  • Changed the operator inbox's write controls to be hidden rather than shown-and-refused for an operator who lacks the permission behind them. The screen is reachable by anyone who may read the support queue, while taking over, replying, closing and assigning each require permission to edit a ticket, so those controls previously rendered for everyone and failed on press.

The admin support desk

  • Changed /admin/crm/support from a table of tickets into the desk console, and moved the table to /admin/crm/support/tickets. See Upgrade Notes — the bulk actions, the export and the analytics are all on the second address now.
  • Changed the desk's default ordering from the age of the ticket to the age of the last message, so the top of the queue is the customer who has been waiting longest for an answer rather than the one who wrote first.
  • Changed the ticket table's heading to All tickets and given it a link back to the desk, so an operator arriving from a bookmark has an obvious way to the screen where the work is done.

Geographic restrictions

  • Changed the restriction policy to be checked before it is written rather than only after it is enforced. See Upgrade Notes — two configurations that refuse every request can no longer be saved at all, and the rest warn first.
  • Changed VPN and proxy blocking to say when it is doing nothing. The switch needs a provider that reports those flags, and with no provider selected — or one that does not return them — no connection was ever recognised as anonymised, while the screen showed the protection as on.
  • Changed the engine to keep a safety override in place across its own settings reloads. Without that, a policy corrected by the cut-out above would be restored from the database within thirty seconds, bringing the platform back up and then taking it down again.

Dependencies

  • Changed 67 packages to current versions, 20 of them across a major version. The notable ones: the charting library, the icon set, the form-validation resolver, the styling-conflict resolver, the payments, messaging and email SDKs, the wallet-connection kit, and the whole test toolchain.
  • Changed the linting toolchain off a prerelease alpha build it had been pinned to, onto the current stable release.
  • Changed the Node type definitions to a single version across all four workspaces. They had drifted to three different major versions in one repository, none of which matched the Node the platform actually runs on.

Web fonts

  • Changed the ten typefaces the platform carries — the three it draws with by default and the seven further families Site Design can switch it to — to ship with the platform rather than be downloaded from Google every time the frontend is built. Nothing changes for your visitors: the fonts were already served from your own server and they still are, so no page has ever asked a visitor's browser to contact Google, before or after this. What changes is that building the frontend no longer needs to reach the internet at all. See Fixed — one failed download used to stop the whole build.
  • Changed how a fork adds an eleventh typeface to the picker. The font files and the stylesheet that declares them are generated, so a new family now needs pnpm --filter frontend fonts:vendor run once after it is added, instead of being a single line of code. That is the one cost of the change and it was accepted deliberately: it buys a build that cannot fail on somebody else's network.

Charts

  • Changed the shared chart kit for the charting library's new major version. Hover tracking on sparklines now reads an index the library changed the type of, and the chart height property accepts only the values the library actually honours rather than any string.
  • Changed the small trend lines inside statistic cards to be marked decorative. The library's new version marks every chart as an interactive region by default, which on a grid of statistic cards would have put one keyboard tab stop per card between a keyboard user and the next real control. Full-size charts keep the interactive treatment, which is where a reader actually wants it.

Chart colours

  • Changed the exchange terminal to take its whole surface from your palette rather than only its background: the grid, the price and time scales, the crosshair, the candles — including the rising and falling colours, on every candle style the chart offers — and the volume bars beneath them. A chart in a customised platform now looks like part of it.
  • Changed the exchange terminal to follow Site Design while it is open. Saving a palette used to leave any chart already on screen on the previous colours until the page was reloaded, because the colours had been copied into the chart rather than read from the theme.
  • Changed the chart engine to stop saving the default volume indicator's colours into the customer's browser. Those colours are meant to follow the theme, and storing them resolved is what froze them; colours already stored that way are discarded on update, so the live palette takes over.

Forms

  • Changed six NFT and ICO forms for the validation resolver's new major version, which now models a schema whose input differs from its output. Three suppression comments that existed only to hide the old version's inability to express that are removed, so those forms are genuinely type-checked again rather than exempted.

Page headings

  • Changed every admin and add-on page heading to one design on one background — the 117 pages built on the admin table, the 77 with their own heading band, and the 69 on the shared page frame. See Upgrade Notes: the change is visible everywhere and affects a customised fork.
  • Changed the pages that previously had no page background at all, and the ones that had a grey wash, onto the same background as the rest. The wash was opaque, so a page carrying it painted over anything its section had drawn underneath.
  • Changed headings to paint at rest. They used to fade and slide their badge, title, description and figures in one after another, finishing up to a second after the content below them had already drawn — so the first thing you saw on a page was its heading arriving late, in the wrong place, and moving.
  • Changed the figures under a page title into one compact rail, replacing the five different treatments that had grown up for the same thing. Eleven pages were placing full statistic cards there — bordered tiles built for a dashboard grid — which on the payment gateway console pushed the alert about gateways that cannot authenticate off the first screen.
  • Changed a figure that reports a state to say so in words. A tinted number in a heading was the quietest possible way to raise something, and on the pages that used it the same fact was already stated in a banner directly below with a button attached.

The add-ons screens

  • Changed Add-ons & Integrations and the individual add-on page onto the same heading and background as the rest of the platform. Both were built by hand and neither had a page background at all: the list drew a brand-tinted band closed with a hard rule, and the add-on page drew a grey wash that faded in and back out over its own height.
  • Changed the eight statistic tiles across the two screens for a short row of figures on the list and a row of status chips on the add-on page. Every one of the eight repeated something already visible above it — see Highlights for which, and why "Unlicensed" is not among what replaced them.
  • Changed the add-on page's product illustration into the heading's right-hand column, drawn whole at the proportions it was made in. Two of its three views had put it in a box a quarter of that size, where the labels inside the image rendered too small to read.
  • Changed the add-on page from four tabs to three. Updates and Changelog were the same release notes, read from the same place, differing only in which version each one picked; they are one Releases tab with the version list beside the notes.
  • Changed the Overview tab from two panels of buttons into the add-on's description, the map of pages it installs, its release state and its identity. It carried six controls, one of them a duplicate of the panel below it, and then roughly 300 pixels of nothing.
  • Changed the version list to compare each release against the build you are running instead of looking for an exact match. See Fixed — the marker it was trying to draw could never appear.
  • Changed the feature, benefit and highlight lists to be drawn one way. A licensed add-on and an unlicensed one rendered the same three lists in two unrelated designs, down to different shapes for the same card.
  • Changed those benefit panels off the platform's success colour. Nothing about a sales claim is a success state, and that colour is reserved for the things that are.
  • Changed an add-on card with no description of its own to show the product's own summary line rather than printing "No description available".
  • Changed both screens to paint at rest. Thirty animated elements between them, none tied to anything actually changing, the longest ladder settling a full second after the page arrived.
  • Changed the search field on the add-ons list to carry a name for screen readers, and the grid and list buttons to announce which one is selected rather than only showing it in colour.
  • Changed Update All to say Check for Updates when nothing is pending, instead of offering a filled button for an action with nothing to do. It is the same control and still worth pressing — the counts on that page come from a ten-minute cache, so the button forces a fresh question.
  • Changed the Back to products button on the add-on page into a proper link, so it keeps your language and can be opened in a new tab.
  • Removed the pulsing from the Update available badge and the matching dot on the Updates tab. Two elements throbbing continuously, out of phase, for one fact that was not changing.

The platform's update screen

  • Changed System → Update onto the same heading and background as the rest of the platform. It was three hand-built copies of one layout — one for while it loads, one for an unlicensed platform, one for a licensed one — and they disagreed on the size of the product tile, the size of the tagline and the width of the description, so the page changed shape the moment a licence verified.
  • Changed the three statistic tiles for a row of status chips. License Status, Current Version and Update Status each restated a badge sitting a couple of hundred pixels above them.
  • Changed the screen from three tabs to none. Updates and Changelog drew the same release notes from the same place, and once those are one thing there is a single subject left — a tab strip whose tabs are the thing and the same thing again, hiding the release notes behind a click on the one screen where reading them before pressing Install is the whole point.
  • Changed the release notes to run in the page, folded at about a screen and a half with a control that opens them in full. A platform release note is not an add-on's: this release's own notes run to roughly a hundred thousand characters, which is thirty screens of prose sitting on top of everything below them.
  • Changed the sequential update queue to be stated once, as the path it will actually take, with the next step marked. It had been announced five times on one screen — a badge, a count on a tab, an alert, a panel description and a list — four of which said only how many.
  • Changed the version list beside the notes to compare each release against the build you are running, the same repair the add-on screens got and for the same reason.
  • Changed the screen to paint at rest. Twenty-one animated elements, none of them tied to anything changing, with the licence form — the only thing an unlicensed platform can act on — arriving last, most of a second after the words describing it.
  • Changed the Back to Dashboard button into a proper link, so it keeps your language and can be opened in a new tab.
  • Removed the Extensions button from the Quick Links panel, which the navigation bar already carries; the panel itself goes with it, leaving the support and documentation links as a footer line. A heading, a bordered card and a three-column grid to hold two outbound links is more structure than two links can justify.
  • Removed the pulsing from that screen's Update Available badge and the matching dot on its Updates tab — the same pair, throbbing out of phase for one unchanging fact, as on the add-on page.
  • Removed the Platform Features panel from the unlicensed screen. Six cards of English-only copy on the screen an operator reaches before they can use the product, two of them offering P2P Trading and Staking as reasons to license a platform that does not include them.

Site Design

  • Changed the design screen to remember which panel you had open. It is worked in for long stretches and reloaded constantly, and it reopened on Presets every time — so a reload during a table-density change meant navigating back to where you were, on the one screen whose job is comparing before and after. The panel is now part of the address, which also makes it linkable: a colleague can be sent straight to the panel in question.
  • Changed the Reset colours to shipped menu item to Reset palette to shipped. See Upgrade Notes — it always cleared the radius, the typefaces and the motion curves as well as the colours, and named only one of the four.
  • Changed the studio's own dividers to span what they divide. The rule between the back link and the page title was a third of the height of the bar it sat in, and the rules between icon groups in the left rail were half the width of the rail — at those sizes they read as punctuation between two items rather than as a division of the region. The same applies to the lists inside each panel, whose separators stopped short of both edges.

Which panel sits above which

Every layer the platform draws over the page — a drawer, a dialog, a tooltip inside that dialog, a guided tour, a toast — has to be told what it sits above. There was no list to consult, so each was given a number by whoever wrote it: fourteen different numbers across 37 places, climbing to 9999. That is how a dialog ends up underneath a header, and it is self-reinforcing, because the only way to beat a number you did not know about is to pick a bigger one.

  • Changed the platform to name each layer instead of numbering it — scrim, drawer, dialog, toast, tour, and the last-resort top. Every value is the one that was already in use, so nothing moved relative to anything else; what changed is that there is now a list to consult, and the check rejects a fifteenth number.

Stylesheets are inside the design check now

The design check had only ever read the platform's component files. Thirteen stylesheets — including the one that defines every colour the platform uses — were outside every guard it has, which is where the two colour faults below had been sitting.

  • Changed the check to read stylesheets as well as components, and to reject a colour, a stacking order or a rule that overrules the design system from inside one.
  • Changed two stylesheet rules that reached further than they were meant to. The page builder's editing styles were written to apply to any editable region anywhere on those screens rather than to the builder's own, and the professional trading workspace was redefining two shared animations for every component drawn on its routes. Both are now scoped to the thing they belong to. Nothing looks different; both were faults waiting for the next component to be added.

Currency icons

  • Changed the report to be measured once and then reused for a minute, instead of being re-measured on every request. Producing it starts a separate process that queries every catalogue table and reads every icon file on disk — a second or two of work that was acceptable when the screen made one request and is not when turning a page, typing in a search box or pressing a filter each makes another. Rescan forces a fresh measurement, and fetching icons discards the old one, so the counts are right the moment a fetch finishes rather than up to a minute later.
  • Changed the outcome column out of the scanner's own vocabulary. would-write and convert-failed now read Ready to fetch and Convert failed — the first of those in particular was the good news on the page, printed as jargon.
  • Changed the page onto the platform's shared heading and background, so it opens the way every other admin screen in this release does.
  • Changed the two options above the fetch button from checkboxes into labelled switches that state what each one does to your filesystem, rather than naming the flag.

The language picker

  • Changed the flag shown beside English from the flag of the United States to the Union Jack, in both places a visitor sees it — the language button in the header and the list inside the language drawer. English (US) and Hawaiian keep the American flag.

Translations

  • Changed the deposit screen's payment button into one message carrying the gateway's name as an argument, in place of sixteen messages that differed only by the brand. Each language now puts the name where its own grammar wants it — German reads "Mit Stripe bezahlen" and Japanese "Stripeで支払う" — instead of the name always coming last. Where a gateway had no message of its own the button was assembled from the words "Pay with" and the brand, which forces that order on every language; in Japanese the result reads "Payment method Stripe". Every gateway the platform ships had its own message, so this was only ever reachable by a gateway added beyond the shipped catalogue — which is the single case that assembly existed to cover.
  • Changed the sixteen gateway names out of the language files altogether. A brand is not translatable, and holding PayPal in ninety files is ninety opportunities for it to come back as something else.
  • Changed labels ending in a percent sign, a required-field asterisk, a colon or an arrow so the symbol belongs to the layout rather than to the sentence being translated. 66 labels across 39 languages had placed that symbol somewhere other than the end — Catalan wrote the percent first — and they now sit the same way round as the rest of the platform. The arrow is the one that could not be left alone: it points the wrong way the moment a page is read right to left, and no translator can correct that from inside the text.
  • Changed every label ending in an ellipsis to a single character. The files spelled it both ways — 281 as three full stops and 59 as the one ellipsis character — so "Loading…" and "Saving..." could appear side by side on the same screen.
  • Changed 272 keys that had drifted into duplicates onto whichever one names the text it holds. Three of them said "Loading" — one plain, one with three dots, one with an ellipsis — which is one word translated three times in each of ninety files.

The admin dashboard waited seven times for work it could do at once

The dashboard asks the database eighteen questions to draw itself — the user counts, the revenue ledger, registrations, the verification tiers, the transaction mix, trading activity. Not one of those answers depends on another, but they went out in seven consecutive rounds: ask, wait, ask again, seven times over. The page cost the sum of seven waits instead of the length of the longest one.

  • Changed the dashboard to ask everything it can in one round. Seven became three, and the two that remain are real dependencies — the currency rates can only be looked up once the ledger has said which currencies it holds. On a small install the queries total eleven milliseconds and the difference is invisible; on a large one, where each of those aggregates is a scan over millions of rows, it is the difference between one wait and seven.

Fixed

An add-on you never bought could throw you off the admin dashboard

Opening /admin could redirect straight to a licence activation page for the AI Support Agent, on an installation that had never purchased or enabled it.

Three things combined. The extensions catalogue ships a row for every product sold, switched off, so a product id exists for add-ons nobody owns. The licence check only ever asked whether a licence file was present, never whether the add-on was switched on, so every unowned add-on answered "licence required". And two background pollers belonging to that add-on run on every admin page, so the refusal arrived before the dashboard had finished drawing and was turned into a redirect.

  • Fixed the licence check to tell not installed apart from not licensed. An add-on that is switched off is simply absent; only an add-on you have enabled can ask you to activate it.
  • Fixed background requests being able to navigate the page. A poller now reports a licence problem to the log; only something you actually clicked can send you to an activation screen.
  • Fixed both pollers calling a paid add-on's API on installations where it is not enabled.
  • Fixed switching an add-on on requiring a backend restart before its pages would load — a consequence of the first fix, caught before release.

The assistant's guides and actions rendered in English in every language

Wording for the assistant's guides, processes and actions is held in the backend and copied into the translation files at build time. The step that reads it looked for the backend's TypeScript source, which is not part of an installation — installations ship compiled code. So on every install the step failed, quietly produced nothing, and those entries were left out of every language pack.

  • Fixed the build step to read the shipped English messages when the source is not present, which is the same set of entries by construction. Verified identical: 352 entries from either source, no difference.
  • Nothing on screen changes yet, because those entries are currently English in every language pack. It changes the moment translations for them are produced.

One solved captcha could fund unlimited simultaneous registrations

The proof-of-work check read a solved challenge from the store at the top of the function and deleted it forty lines later, under a comment saying that was what prevented reuse. It prevented reuse only against a caller polite enough to wait. Requests are served concurrently, so the read itself was the window: several arriving at once all found a live challenge, all validated it, and all passed.

Measured against a running install, six simultaneous registrations carrying one solved challenge saw three of them accepted. The real cost of an account was therefore one solve divided by however wide an attacker chose to open the fan-out — a captcha that reported itself as working while charging effectively nothing.

  • Fixed the challenge being read and deleted as two steps. It is claimed in one, so exactly one request can hold it.
  • The platform's own test for this could not have caught it: it sent the second request only after the first had finished, which is the one sequence the fault does not occur in. A test that fires them together is included, and it was confirmed to fail against the old behaviour before being accepted as passing against the new.

Every named rate limit was one bucket shared by the whole internet

See Upgrade Notes — this changes behaviour you may have been receiving support tickets about.

  • Fixed the caller's address being read from properties that do not exist on the request object these limiters are handed, which resolved every caller to the same key. Wallet login, password reset, verification resends, login one-time codes and account deletion were each metering the entire world against a limit sized for one person.

A fresh install ran no captcha while the screen said it was on

  • Fixed the enabled check treating a missing settings row as off. No installation seeds that table, so the missing row is what every new install has, and the captcha did not run on any of them.
  • The admin screen reported the opposite throughout, because it draws an unsaved switch at its shipped default — and since a save only sends settings you have changed, agreeing with that default could never write the row that would have made it true. See Upgrade Notes.

The captcha challenge endpoint trusted a header the caller wrote

  • Fixed the per-address cap on issuing challenges being keyed on a raw X-Forwarded-For value, read directly and with no check that the platform is behind a proxy at all. A caller could vary that header per request and never meet the cap.
  • Fixed the same code falling back to a literal unknown when it could not find an address, which on any install without a proxy put every visitor in one bucket and refused registration and login to whoever arrived eleventh in a given minute. That self-inflicted outage is a large part of why operators switched the captcha off.

Every table on the platform rounded crypto amounts to three decimals

Any figure at or above a thousandth was printed to three decimal places, whatever it was denominated in. Three is a reasonable ceiling for dollars and a wrong one for an asset held in eight, and it applied to the shared table cell every admin and customer list is built from — including the customer's own transaction history.

Reported by a customer who had been credited 0.0099 BTC and read their history as +0.01: the exact figure they would have received if the platform fee had never been taken. They reported a payout that had not arrived. The money was correct and the screen was not, which is the worse way round — a wrong figure on the one page people reconcile against their own records sends them to your support desk to dispute a transaction that never went wrong.

It was never only cosmetic. At the top of the truncated band the error is half a thousandth of a unit, which on BTC is tens of dollars a row.

  • Fixed the number cell keeping every decimal a value actually carries, to eight. Ordinary money figures are unchanged — the rounding only ever bound on values with more precision than it allowed — and trailing zeros are still dropped, so a fiat amount does not grow a tail of them.
  • Fixed the wallet history's amount column being a hand-copied second copy of that cell's arithmetic, kept in step by a comment and carrying the same defect. There is one implementation now.

A losing investment was shown to the customer as profit

The amount recorded against an investment is stored unsigned — the same positive number whether the outcome added it to the principal or took it away. Nothing that read it consulted the outcome, so a position that settled LOSS displayed the amount lost in green, with a plus sign, under the word "profit".

  • Fixed the sign. Gains and losses are now read together with the outcome that produced them, so a loss is negative, in the destructive colour, with a minus. A DRAW reads as zero rather than as a gain of nothing.
  • See Upgrade Notes — this is one of four investment figures that change after updating.

An investment reported its full return the moment it was opened

  • Fixed a position's projected return being counted as earned. The purchase writes the whole projected amount to the record when the position opens, so a customer who invested a minute ago was shown that entire return as banked profit. Realised figures now appear only on positions that have actually settled; a running position shows what it will return, labelled as such.

The investment portfolio counted refunded money as invested, and added currencies together

  • Fixed "Total portfolio value" and "Total invested" including cancelled and rejected positions. Principal returned to a wallet still counted as capital at work.
  • Fixed those totals being summed across currencies and stamped with a dollar sign. A customer holding a BTC plan and an NGN plan had 0.5 and 40,000 added into one number and shown "$40,000.50" — and there is no dollar sign anywhere in that page's text; it came from a formatter's default. Totals are reported per currency now, because this browser has no exchange rates.

The investment dashboard printed a translation label where the plan's name goes

  • Fixed every row of the dashboard reading "Investment Plan" as the plan's name, and the active position reading "Investment Duration" as its term. Both are static interface labels that had been placed in the slots the real values belong in — and the real values were on every row of the response the whole time. Every investment a customer held was therefore described identically.

A signed-out visitor could be shown a confident, empty investment portfolio

  • Fixed the investment dashboard having no check for who is asking. Its loading flag started false, so before any request was made it rendered a complete portfolio of zeroes — a total of nothing, no active positions, nothing completed — to a visitor who was not signed in and to a customer whose session had expired. It now asks who is looking first, and offers a sign-in.
  • Fixed the same class of fault throughout the product: a failed request and an empty account are now different screens. Previously a load that failed left the page saying the customer had no investments, which to somebody with capital committed is a false statement rather than a missing one.

A failed plan load retried forever

  • Fixed the investment plan pages re-requesting without pause when a request failed. The guard that stopped a second request was only set on success, so a failure re-triggered it immediately and the page issued requests as fast as the server could refuse them — with an error notification for each one — for as long as it was left open. A backend that was merely slow to start was enough to trigger it, from every open tab.

One customer's investments could appear in the next customer's session

  • Fixed the investment data outliving a sign-out. It is held in memory for the life of the page, and nothing cleared it when the account changed, so signing out and back in as somebody else showed the previous account's positions until the first request came back.
  • Fixed the plan page reporting a plan as missing on every cold load. It searched the list of currently-open plans instead of asking for the plan by name, and that list is empty for the moment before it arrives — so the not-found message was reachable on every direct visit, and permanently for any plan an operator had closed since.

Investment settlement notices linked to a page that does not exist

  • Fixed the completion notification and its View Investment button pointing at /investments/<id>. The product is at /investment — singular — so every one of those links has always landed on a not-found page. See Upgrade Notes.

Cancelling an investment could fail with a server error instead of a refusal

  • Fixed the cancel route having no answer for an unrecognised investment type. Its two sibling routes both reject one cleanly; this one carried on with nothing to work from and failed with a server error instead of saying what was wrong. It had no callers until this release, so the fault was unreachable — and would have become reachable the moment the new Cancel button shipped.
  • Fixed the same route returning an empty body where its own documentation promises a confirmation message.

Plan images were cropped to a shape they were never uploaded in

  • Fixed the customer's investment pages cropping plan images to a fixed band. An upload is bounded to 1024×728 fit inside, which keeps whatever proportions it arrived with, so real plan images run from square avatars to wide screenshots — and a square cropped into that band lost over half its height. Images are contained now, never cropped. See Upgrade Notes.
  • Fixed a missing image file drawing the platform's generic placeholder, which is a 1200×1200 canvas with its artwork confined to the middle third — at thumbnail size it collapses to an illegible speck, and being loaded as a separate document it cannot read the platform's dark mode, so it painted the wrong shade against it.

A landing page hero left an empty column when it had fewer figures than expected

  • Fixed the figures under a landing page's headline always being laid out in three columns regardless of how many there were. A page with two — which happens whenever a figure is omitted rather than printed as a confident zero — put them in the first two and left the third empty, so a centred hero read as visibly off-centre under its own buttons and the gap looked like a figure that had failed to load.

Images sent in live chat were never stored

The widget uploaded the file, sent the platform its address, and the platform read three other fields and ignored it. What both sides were left with was a message reading "Shared an image: receipt.png" and no image — on the customer's screen and in the agent's thread. Nothing errored, and the upload itself had genuinely succeeded.

  • Fixed the live-chat message route discarding the attachment. Images sent from the widget are stored on the message and render on both sides.

Ending a live chat told nobody

  • Fixed the end-of-chat notice being sent in a shape no support screen listens for, so no open page and no admin thread ever saw it. The widget only appeared to work because it cleared its own state immediately afterwards. It was also sending the entire ticket, transcript included, back over the socket to replace information the recipient already had.

Closing a ticket left it showing as live

  • Fixed ticket closure never reaching the AI support add-on. The add-on has always published a handler for it and nothing in the platform had ever called it, so a resolved conversation stayed marked as active indefinitely — visible in the add-on's console as a live conversation, and never recorded as ended in its handover log. All five routes that close a ticket now report it: the customer's own close, ending a live chat, and the three admin paths.
  • Installs without the add-on are unaffected.
  • Fixed the floating chat button rendering behind the page footer once a customer scrolled to the bottom, which is exactly where it sits. The two were on the same layer and the footer was drawn later, so it won.

Everything an answer carried was thrown away before the customer saw it

Support messages can carry more than text — the source an answer came from, buttons that take a customer straight to the right page, a numbered walkthrough, and the marker that says a message was written by an assistant rather than a person. The admin console rendered all of it. The customer's ticket page and the chat widget each rebuilt the message list in their own way and kept four fields: who, what, when, and the text.

  • Fixed both customer surfaces discarding the rest. They now share one renderer with the console, so sources, action buttons, walkthroughs and the author marker reach the customer.
  • Relevant only where a message carries those extras, which today means the AI support add-on.

A customer who sent a message was shown nothing at all

  • Fixed the support screens giving no acknowledgement after a message was sent. Where nothing is generating a reply the panel simply sat empty, which is indistinguishable from the product being broken. It now states that the message arrived and that a reply will appear in the same place.

The same notification could be delivered twice

Whether a notification had already been sent was checked at the start and recorded at the end. Two of them arriving at once — which is exactly what a caller retrying after a timeout produces, because the first attempt is still running — both looked at the record, both saw nothing, and both delivered. The database could not catch it either: the column that identifies a repeat is indexed for lookups but does not refuse duplicates.

  • Fixed duplicate notifications under concurrent delivery. A sender now claims the right to deliver in a single indivisible step, so a second attempt for the same event is turned away rather than racing it. Ten simultaneous attempts now produce one notification; before this they produced ten.
  • Fixed an attempt that failed part-way holding its claim afterwards, which would have made the retry look like a duplicate and deliver nothing at all. Claims are released on every failure path and only ever converted into a real record on success.
  • Changed the behaviour when the cache is unreachable: delivery proceeds rather than being skipped. A notification arriving twice is an annoyance; a withdrawal nobody was told about is not.

Delivery records were being overwritten by each other

A notification's per-channel record — sent, failed, which provider, what the error was — was kept as a single document that every channel read, edited and wrote back. Channels are delivered at the same time, so the last one to finish erased the others. What survived was whichever channel happened to be slowest, and the record most likely to be lost was the failure an operator would go looking for.

  • Fixed delivery records losing channels. Each channel now records itself independently and cannot overwrite another.
  • Fixed in-app notifications recording themselves twice — once by the channel and again by the service, with the second copy holding less information and guessing the provider name from the message id.

Notifications spent more time counting themselves than delivering

Every notification updated a set of counters, and each counter cost two to three separate calls to the cache, one after another. A single notification spent eight to twelve of them on statistics on top of the two or three doing the actual work.

  • Changed the counters to update together in one call.
  • Changed the recipient's preference lookup to happen once instead of twice. It was loaded to check whether the notification type was allowed and loaded again to check the channels — and on a cold cache neither had finished storing the result when the other started, so both went to the database for the same row.
  • Changed the "who has this permission" lookup — a three-table join, previously run in full on every group notification — to be reused for a minute. Someone newly granted a permission may miss up to a minute of notices; the alternative was the same join, returning the same answer, several times a second.
  • Fixed a database transaction being opened to deliver a notification the recipient had switched off.

One notification to your admins could stall the entire platform for thirty seconds

Anything that notifies a group of staff — an escalated support conversation, a withdrawal awaiting approval, a KYC submission — contacted every recipient at the same instant, and each one needed a database connection. The connection pool holds 25. A platform with more than 25 staff on the relevant permission therefore ran out of connections the moment one of these notifications fired.

Not just for the notification. Every request in flight, on every page, for every customer, queued behind it and failed thirty seconds later. The platform then recovered on its own, which is why this reads in the log as an unexplained thirty-second outage rather than as anything to do with notifications.

It could not happen on a small team and it gets worse as you hire. Observed on a platform with 34 admins: a customer asked a support question, the assistant handed it to the desk, the desk was notified, and the backend stopped answering for thirty seconds — including the support conversation that had sent the notification.

  • Fixed group notifications contacting every recipient at once. They are now delivered in small batches, so a notification can never consume the connections the rest of the platform is using. Every recipient is still contacted, and one recipient failing still does not stop the others.
  • Fixed the underlying reason a single notification needed two connections at once rather than one — it opened a database transaction and then, inside it, went looking for the recipient's notification preferences on a second connection. With enough of them at once, each held one connection while waiting for another that could never be freed. That is a deadlock, not congestion, which is why it always cleared at exactly thirty seconds.
  • Fixed a database transaction being opened to send a recipient a notification they had switched off.
  • The batch size can be set with NOTIFICATION_BATCH_CONCURRENCY; the default suits any platform, and raising it above your connection pool re-creates the fault.

Three notices, one fact, and they contradicted each other

A conversation that had been handed to the desk told the customer so three separate times: a notice in the transcript, an acknowledgement under the last message, and a status line beside the box they type into. Each was written by a different part of the screen and none of them knew about the other two. Worse, the first of the three was preceded by "you're chatting with an AI assistant" — the notice that introduces an assistant, printed immediately above the one announcing it had left, on conversations where the assistant had never written a word.

  • Fixed the assistant introduction being anchored to the handover notice. It now appears above the assistant's first actual reply, and not at all where there was never a reply to introduce.
  • Fixed the acknowledgement repeating a promise already on screen. It appears only when nothing else on the page says who is coming, which is the case it was written for — a new ticket nobody has answered.
  • Fixed "Talk to a person" remaining on screen, greyed out, beside the words "Waiting for a person". The button now leaves when the request lands.
  • Changed the handover notice to read as the interface rather than as the assistant. It is drawn as a centred system note and was nevertheless written in the first person, so a customer read "I've passed this to a member of the team" in a caption styled as coming from nobody.

A conversation the assistant was answering said "Not assigned yet"

The support agent panel reads the ticket's assigned staff member, and an assistant is not one. So on a platform running AI support the panel reported an unassigned ticket, and the progress timeline showed the assignment step as not yet reached, while the assistant was mid-conversation — in the one place a customer looks to check whether anybody is coming.

  • Fixed the ticket details panel ignoring the assistant. It now names the assistant as the assignee until a person takes over, carries the same assistant marker its messages do, and says whether it is answering or has handed the conversation on.
  • Fixed the assistant having no name, avatar or introduction text on any platform that never created a persona. That table is optional and most installations leave it empty, and every customer-facing surface treated an empty one as "there is no assistant" — which also meant the assistant introduction, which needs a persona to draw, had never appeared on those platforms at all. A default is now used where nothing is configured.

Taking a conversation over emptied the reply you had just written

  • Fixed the operator inbox clearing the message box every time a conversation changed hands. Taking over is frequently the step before sending the reply you have already typed — the assistant is answering, you decide it should not be, you write the answer and then claim the conversation — and every one of those presses reloaded the conversation over the top of what you had written.

A long system notice pushed the ticket screen wider than the window

  • Fixed a notice in a customer's transcript being unable to wrap. Short ones looked correct; a longer one — the sort a conversation about fees produces — set the width of the whole message column from its own unbroken length, which pushed the page past the edge of the window, put a horizontal scrollbar under it and dragged the panel beside it off screen.

A tab in the customer's conversation panel did nothing

  • Fixed the Ticket tab being unselectable whenever the conversation had cited sources. The panel re-resolved which tab to show and only ever accepted the two that depend on the conversation's contents, so asking for the ticket's own details silently returned you to the sources — the tab drew, highlighted nothing and did nothing on press.

Opening a conversation flashed the wrong page

  • Fixed the ticket list's loading outline being drawn for a moment when opening a single conversation — a heading, a filter bar and six list rows, on a screen that is none of those things — before being replaced wholesale by the conversation.

The exchange chart did not draw, and the console named a colour

The chart is handed the colour of the panel it sits in so that the two match. It was being handed it in a form the charting library cannot read — and that library works out the contrasting ink for the chart background while it is constructing the chart, so a single unreadable colour is not a mis-coloured chart, it is no chart. What an operator saw was an empty panel where the chart should be, and Passed color string does not match any of the known color representations in the browser console.

  • Fixed the chart being handed a colour its own library cannot parse. Every colour the platform gives it is translated first, and one that cannot be translated is left out entirely so the chart falls back to its own default instead of refusing to draw. This no longer depends on what Site Design contains.
  • Fixed a browser that had already stored the unreadable colour staying broken afterwards. The chart keeps its own copy of its settings, and that copy outranks anything the platform sends it, so the fault would have survived the repair on any machine that had loaded a chart before it. The stored copy is now corrected on load — no cache clearing, no private window.

Your palette was discarded every time a chart opened

  • Fixed the exchange terminal applying your colours at the same moment it switched between light and dark rather than after it. Switching theme is not instantaneous, so the theme's own colours landed second and won. On a theme toggle this hid itself — something else reapplied the palette a moment later — but when a chart first opened there was nothing to rescue it, and the chart simply wore the library's stock colours.

Filled chart shapes were painted grey

  • Fixed the chart engine painting mid-grey wherever it needed to show a colour faded: the band fills on Bollinger Bands, Keltner and Donchian channels, ATR bands and price channels, the Ichimoku cloud, filled area indicators, volume and market profiles, and the fill inside every shape you can draw on a chart. Six copies of the same colour parser sat behind those, each understanding only hex values and each answering grey for everything else — and the platform's colours have not been hex for some time. Twenty-three places drew through them. The one thing that still worked was a colour a customer had picked by hand in the indicator settings, because a colour picker returns hex; every colour the platform chose for itself came out grey, on the shipped palette as much as on a customised one.

An indicator kept the colours of whichever palette a customer first saw

  • Fixed the chart engine storing the default volume indicator's colours in the customer's browser the first time they opened a chart. Colours that are meant to follow the theme were resolved to fixed values at that moment and kept, so those volume bars stayed on the palette that happened to be live then — through every later theme switch and every change you made in Site Design. It affected only the customer whose browser held it, which is why it would never have shown up on your own screen.

A failed update check was reported as "you have the latest version"

  • Fixed the add-on update check answering with the same "you have the latest version of the product" message whether the licence server had said so or the request to it had failed. See Upgrade Notes — the practical effect is that an update you were entitled to could simply never be offered, with nothing on screen to suggest anything had gone wrong.

The update-check endpoint did not require signing in

  • Fixed the route that reports available add-on releases running without an authentication requirement, which also meant the permission it declared was never enforced. See Upgrade Notes for exactly what was and was not reachable.

The platform's own update screen reported a failed check as "You're Up to Date!"

The reason was already being sent to it. The licence check separates a real "you are current" from "no purchase code on this install" and "the licence server could not be reached", because the bulk add-on updater needs that distinction to know when to stop looping. System → Update never read it, and drew a green tick on all three — including on its own branch for a backend that never answered at all, where it printed the network error underneath the success as though explaining it.

  • Fixed the platform's update screen collapsing three different answers into one. It now names which of them it is in, and carries the licence server's reason where there is one. See Upgrade Notes.

A purchase code the licence server rejected produced no message at all

  • Fixed an activation failure on System → Update going nowhere. The reason was written into a field that screen does not draw, and the request is made in a mode that suppresses the usual error notice — so a mistyped code, a code already bound to another machine, and an unreachable licence server were indistinguishable from each other and from nothing happening. The button stopped spinning and the form sat there.

A failed platform update looked exactly like a press that did nothing

  • Fixed a failed Update Now on System → Update reporting nothing, for the same reason as the activation form above, while the screen carried on offering the same update as though it had not been pressed. The failure is now stated, with the update still offered so it can be retried.
  • Fixed a successful update's confirmation being overwritten a second later. The check that runs after an install replaces everything that screen reads, so with more releases queued the count of files replaced appeared and was gone before it could be read. It now survives that check.

The update screen stayed a loading skeleton when the backend could not be reached

  • Fixed System → Update having no failure state. When the request that identifies this install failed, it left the same empty value it holds before the first request, so the screen could not tell "still loading" from "there is no answer coming" — and an operator whose backend was down or restarting sat on a skeleton indefinitely, with no message and nothing to press. It now says what failed and offers a retry.

Release notes were reported as missing while they were still arriving

  • Fixed the release-notes panel showing "No changelog available for this version" for versions that had one. Its loading indicator was tied to the wrong request — the small index of which versions exist, not the notes themselves — so it stopped the moment that index landed, several seconds before the prose it was waiting for, and the gap between the two was reported as an absence.

Pressing Refresh during an update run removed the only way back into it

The add-ons list empties itself before it re-reads, so for the length of every Refresh it believes nothing is licensed. Update All was shown only while something was, so it disappeared for that second — and while a run was in progress that button is the run: it carries the progress count and it is what reopens the queue. Pressing Refresh mid-run therefore took the queue away, with no other route back to it.

  • Fixed the button staying put through a refresh, and remaining available for as long as a run is going.
  • Fixed the figures on that screen holding their last known values while it reloads, rather than dropping to zero and back. The category counts on the tabs did the same thing, so the whole heading read "0" for a second on every press.

An add-on's release notes were cut off mid-sentence

The notes were shown through fixed-height windows — three of them across the two tabs that displayed them — on a page that already scrolls. Whatever did not fit was simply cut, so the notes for a pending update ended part-way through a heading, and the block of commands you were meant to run before updating was severed halfway down.

  • Fixed the release notes being clipped. They run at their full length, in the page, with no window inside a window.

The changelog could never mark the version you are running

An add-on's installed build is frequently not in its published release list — notes are written per release and an install can sit between two of them. On this platform's own AI Investments add-on the install reports 6.0.1 while the published notes run 6.1.1, 6.1.0, 6.0.5, 6.0.2 and 6.0.0.

  • Fixed the version list looking for the running version by exact match, so the "current" marker it promised simply never drew and the list gave no way to tell what you already had from what was ahead of you. Each release is now compared against your build, and says which it is.
  • Added a line reconciling the release list with the update check when the two disagree. They are separate services with separate caches, so the notes can list a version newer than the one being offered — which read as a fault, because one tab was advertising a release the other had never heard of.

A fresh install stopped seeding partway through, and said nothing about what it skipped

A new install imports initial.sql and then runs pnpm seed against it. Nothing creates a table between those two steps — the backend's schema sync does not run until the platform first starts, several minutes later — so the seeders see whatever that file contained and nothing else.

That file had fallen 83 tables behind the platform. It was a snapshot of a database rather than something derived from the product, so every table added since the last snapshot was simply absent from it: the fiat payout gateways, the audit trails, the geographic-restriction records, the TransFi customer records, and the whole of Swap, Forex Trading, Trading Bot, Hummingbot and AI Support.

On an existing install none of that was visible, because the first start creates whatever is missing. On a new one the seed run stopped at the first seeder to touch a missing table and abandoned every seeder after it, which is how an install finished with no Swap tokens, no AI Support assistant and no repaired ticket statuses while its log showed one line about a table:

== 20260802000001-transfi-withdraw-gateway: migrating =======
ERROR: Table 'zervex.withdraw_gateway' doesn't exist
  • Fixed initial.sql, which is now generated from the platform's own data model rather than dumped from someone's database. It carries all 243 tables, and every seeder completes on a clean install.
  • Fixed the TransFi payout seeder aborting the run when its table was absent, rather than skipping the way every other add-on's seeder already did.
  • Changed the shipped schema to a single text collation throughout. The old file was split between two, which is a fault waiting on a query that joins across the boundary.
  • Removed the accumulated wreckage of years of automatic schema updates that the old snapshot had preserved along with everything else — 297 duplicate keys and constraints, including one table carrying 63 copies of the same uniqueness rule.

Existing installs need no action. This changes only what a new install starts from; your schema is already whatever your platform built for itself.

Every add-on's page hid its own toolbar behind the navigation bar

  • Fixed the add-on detail page reserving no room for the platform's navigation bar. The page's own bar — carrying Back to products and the licence badge — sat in exactly the band the navigation occupies and lost, so it was neither readable nor clickable, and because that bar is pinned it stayed hidden however far you scrolled. The only way back to the add-on list was the browser's back button.

Platform losses taken while the treasury was empty were dropped

  • Fixed recordPlatformLoss refusing to save a loss when no treasury transaction backed it. The refusal was caught and logged as a warning rather than raised, so the loss vanished and the platform's net profit stayed higher than it should have been. See Upgrade Notes — the practical effect is that your reported profit was too high by the total of every loss absorbed on an empty treasury.

A user's role could not be changed at all

Opening an account in CRM → Users, choosing a different role and pressing Save answered "Your current password is required to change email, role, or 2FA" — and there was no password field on that form, or on any other screen in the platform. The requirement could not be met by anybody, so the role stayed where it was and the same wall stood in front of changing a user's email address. A Super Admin was refused along with everyone else, which is the part that made it total: they are the only role that can reassign an account in the first place.

  • Fixed the re-confirmation being impossible to satisfy. A Super Admin is no longer asked for it — there is no privilege above them to escalate into, and every edit they make is already written to the admin audit trail.
  • Added a Current password field to the user edit form for every other administrator, shown only to them and needed only when they change that account's email address. Name, phone, status, verification and profile save without it, as they always did.
  • Fixed a role change made by an administrator who is not a Super Admin reporting "User updated successfully" while discarding it. Reassigning a role has always been Super-Admin-only; the screen never said so, and the role simply reappeared unchanged on the next load. It is now refused in words.

The Audit tab on a record listed the whole platform's history

The Audit Trail tab added to customer, deposit, withdrawal and transfer records in v6.6.0 was never scoped to the record it sits on. Opening a customer listed every administrative action taken anywhere on the platform, and the balance ledger beneath it listed every customer's credits, debits, holds and releases rather than that customer's. Nothing on screen suggested a fault: the entries are real, dated and correctly attributed, so the tab read as a busy account rather than as an unfiltered list — and the busier the platform, the more convincing it looked.

  • Fixed the tab asking for one record's history and being answered with the whole table. The request named the record it wanted in a form only the admin tables use, and anything else was discarded on arrival — a list with no filter left on it is answered with everything, without an error anywhere.
  • Fixed the balance ledger in the same tab, which was the same fault with worse consequences: one customer's page was showing the money movements of every customer on the platform to any operator who could open it.
  • A bulk administrative action still appears only on the first record it was applied to. Every record it touched is stored against it, but only the first is indexed, and looking the others up would cost a full scan of the trail on every record page anyone opens.

A blank optional date was rejected as "Invalid date"

  • Fixed admin forms refusing to save whenever an optional date was left empty. See Upgrade Notes — the practical effect is that fields documented as leave-empty could only be satisfied by inventing a date, which on the geographic restriction screen changes when a rule is enforced.

A required date said "Invalid date" instead of naming itself

  • Fixed a mandatory date field left untouched reporting "Invalid date" rather than naming itself, the way "Effective from is required" does now. Describing a missing value as a malformed one was reachable for dates alone; every other field type already named itself.
  • Where: the eight screens with a date you must supply — a discount's validity, a shipping delivery date, a gateway payout period, an ICO offer's end date, an auction's end time, a staking position's term, a P2P dispute's filing date and a binary-engine cooldown's expiry.

Settings descriptions were cut off after two lines, and a dropdown's was never on screen at all

Every setting on the platform carries a sentence explaining what it does, and those sentences put the load-bearing clause last — what it costs, what it needs, what it breaks. A toggle showed two lines of it and then an ellipsis, which at a typical admin window is about 140 characters. Require 2FA to Withdraw was cut before "changing this requires a Super Admin account". Enforce KYC Feature Access was cut before the warning that customers whose level does not list a feature will be refused that action. The deprecated Auto-Approve Withdrawals (Legacy) was cut before the sentence saying it is ignored once the current setting has been saved — the one fact that tells you whether the switch in front of you is doing anything at all. Thirty-five of the platform's toggles carry a description longer than the card would show, and the longest runs to 501 characters.

A dropdown was worse: its description was not printed anywhere. It existed only inside a tooltip on a small icon beside the label, which nothing on a touch screen can reach and nothing on screen suggested was worth pressing. Token Allowlist Mode is one of the twenty: the sentence explaining that moving off allowlist is the single change that lets a customer swap into a honeypot, irreversibly, lived behind that icon.

  • Fixed every settings description printing in full, beneath the control it belongs to, on all 19 settings screens — the platform's own and every add-on's. A setting whose add-on is not licensed now carries its description too, where the screen had shown a greyed control and nothing explaining what it was.
  • Fixed the category list beside them cutting every entry to one line in a column about 28 characters wide. 57 of the 78 descriptions in that list are longer than that, so the rail an operator reads to choose a section was a column of three-word fragments.
  • Removed the information icon from every settings field. It carried the same sentence now printed below it, and on the fields that had both it was the description said twice.

Page titles were rendered with a doubled space

  • Fixed headings whose title is written in more than one part printing a stray space after the last word, and a double space between the parts wherever the page had also written one in — "Payment  Gateways", "Discover  Staking Pools". Seventy-seven titles were written that way, and the spacing reached screen readers as well as the screen. Forty-four of them only existed in parts so that one word could be a different colour, and are now single strings.

Helpful and unhelpful FAQ feedback were told apart by colour alone

  • Fixed the FAQ feedback console printing its helpful and unhelpful counts as green and red text on one line, with nothing but the colour to say which was which. They are now two separate figures, each with its own icon and its own word.
  • Fixed the FAQ questions console running two counts together as "12 pending 40 answered", with no punctuation between them.

A split heading ignored which way it aligned its right-hand content

  • Fixed the alignment setting on headings that place content beside the title never taking effect. All three of its values produced the same layout, because the style it selected was assembled in a way the styling toolchain cannot see and therefore was never generated at all.

The staking landing page rendered developer comments as page text

  • Fixed four sections of the public staking page — featured pools, pool variety, the token showcase and the calculator preview — displaying an internal developer note to visitors whenever that section had nothing to show. The note had been written into the position the page's own text goes rather than into a comment the browser ignores. A staking page with no pools published yet is the state that displayed it, so look at yours after updating if that describes you.

Two backgrounds were being discarded before they reached the browser

  • Fixed the styling-conflict resolver mis-grouping two classes and dropping the loser, which deleted a green background on the forex investment page's primary button and a gradient on the copy-trading dashboard's highlighted card. Both were written correctly and neither had ever rendered, so those two screens gain a visual element — check them if you have customised either.
  • Fixed a dead styling class on the blog search box that was overriding its focus ring and leaving it colourless.

Three redirects dropped the visitor's language

  • Fixed /forex-trading, the wallet type page and the retired /admin/appearance screen sending visitors to the platform's default language rather than the one they were reading in. Only visitors on a non-default language were ever affected, which is why this survived — on a single-language installation the behaviour was correct by accident.

Every paginated screen said "Previous" in English

The word was stored under two keys. The tutorial overlays, the mailwizard campaign builder and the support ticket pager read one of them, which was translated; the shared pagination control read the other, which never was. So Previous appeared in English on all 89 translated languages, immediately beside a correctly translated Next — on the control that sits under most tables in the product.

  • Fixed it by merging the two keys, which hands the pagination control the translation that already existed in 15 languages.
  • The other 74 had no translation under either key and still read English. That is a gap in the language files rather than a fault in the control, and the next translation pass closes it.

The icon picker would have thrown on three entries

  • Fixed the admin icon picker listing three non-icon exports from the new icon library as if they were icons. It assumed everything in that export list could be drawn, so selecting one of the three would have thrown — a fault prevented before it could be reached, not one you will have seen.

The currency icon report named its asset classes in English on every language

  • Fixed the four asset-class labels on System → Currency IconsSpot / CEX, Ecosystem token, Fiat and Stocks & commodities — being written into the page as English text rather than drawn from the message files. They appeared on every row of the table and on the summary above it, so the one column that says what each currency is was untranslated on all 90 languages while the rest of the screen was translated around it.

Five NFT admin columns rendered every row the same grey

A table column that colours its values has a fixed set of colours to choose from. Five columns across the NFT add-on's admin screens were asking for colours by names — blue, green, purple, yellow, orange — that are not among them. Nothing errors when that happens; the chip falls back to grey. So the listing-type column, the collection-standard column and three currency columns each drew every row in one colour, and the distinction the column exists to make was not being made on any of them.

  • Fixed all five onto the platform's own status colours, so Fixed price, Auction and Bundle are told apart again, as are ETH, USDC, USDT, BNB and MATIC, and ERC721 against ERC1155.

Seventeen status chips were below the readability floor

A tinted chip — a pale wash of a colour with the same colour as its text — measures between 3.94:1 and 4.48:1 against its own background. The accessibility floor for text that size is 4.5:1. The platform has a darker ink derived for exactly this pairing; seventeen chips were not using it.

  • Fixed seventeen chips across deposits, staking, KYC, the DEX confirmation dialog, the forex funding view and the support screens, which now use the paired ink. They read as the same colour and are legible.

Fourteen labels were painted in another status colour's ink

A green Success fill carries green's ink, an amber Warning fill carries amber's. Fourteen places paired a fill of one status with the ink of another. It is legible today only by coincidence — every one of those inks currently resolves to nearly the same value — so the moment an operator tints one of them in Site Design, those fourteen labels change colour on a background they were never paired with.

  • Fixed all fourteen, across NFT creation, copy trading, forex, staking and the FAQ troubleshooter.

The selected algorithmic-trading strategy looked unselected

The strategy chips in the algorithmic trading panel mark the active one with a ring, a tint and a coloured border. All three were drawn from a colour the stylesheet described at length and never actually defined, so all three were discarded and the selected chip rendered identically to the ones beside it. Three icons in the same panel were drawn from the same missing colour and had no colour at all.

  • Fixed by defining it. The selected strategy is distinguishable again, and the check now rejects any style drawn from a colour nothing defines — the failure is silent by nature, because the rule reads as correct and the only symptom is that something is missing.

Every blog author page asked a reader to sign in

The byline on a post links to that author's page. It has always been the second most-followed link on the blog, and to a signed-out reader it answered "Authentication Required: Missing session ID" — the endpoint behind it demanded a session. The two endpoints beside it, the authors list and the top-authors strip, were opened to anonymous readers in an earlier pass and this one was missed, so the list of authors was public while every author it linked to was not. Search engines saw the same wall.

An author's name, picture, biography and published articles are public by definition — they are printed on every post they write. Their account is not, and this endpoint was returning rather more of it than a byline needs.

  • Fixed by making the page public. A signed-out reader, and a crawler, now sees the author.
  • Fixed the same endpoint returning the author's profile record whole. That record is free-form and owned by the account holder: it holds a postal address, and — because the SMS flow keeps its state there — a live phone verification code while one is in flight. It is now reduced to the fields a byline needs, by a list of what may be published rather than a list of what may not, so a field nobody has thought of yet is withheld by default.
  • Fixed the page listing the author's unpublished drafts to anyone who opened it. Only published articles are returned now.
  • Fixed an author whose application is still pending, or was rejected, being readable by anyone who guessed the address. The list has always excluded them; the page did not, so the review queue could be read one identifier at a time.
  • Fixed the article list shipping every article's full text in order to draw a grid of headlines.
  • Fixed the author's social links, which had never rendered at all — the same list that decides what may be published was dropping them. They render again, and because the profile editor asks for handles rather than addresses, a stored mashdiv now becomes a link to that account instead of a link to a page on this platform that does not exist. Anything stored there that is not a web address is withheld.
  • Fixed the page pulsing its loading skeleton forever, with nothing in view to explain it, for any author whose user record had been removed.

Blog headings drew a shadow that the Elevation control could not reach

Headings that sit over a photograph carry a soft shadow so they stay readable. The platform's shadow colour became a setting earlier in this release — but a second, separate kind of shadow was never included, so eleven headings, most of them on the blog, were drawing a hardcoded black instead. On a dark theme a black shadow over a dark image does nothing at all, which is precisely where the heading most needed it.

  • Fixed by bringing that second kind of shadow onto the same setting as the first. The eleven headings now follow Elevation and are legible in both themes.

Fifty-nine cards carried elevation the card design had removed

The platform settled on one card: a hairline border, no shadow, no lift on hover. The reason is measurable rather than stylistic — a shadow on a near-black surface is invisible, so a card that leans on one looks correct in daylight and flat at night, giving the product two different card languages depending on the theme.

  • Fixed fifty-nine cards that still carried a shadow or a non-standard corner, across the blog admin, KYC, copy trading, staking, notifications, the admin settings screens, the ICO roadmap and the NFT creator. Where the shadow was a hover response, it is now a border that strengthens — which says the same thing and says it in both themes.
  • Fixed the four landing-page card styles that offer elevation as a deliberate choice. Those keep their shadow, but it was ignoring the Elevation control, so an operator setting elevation to zero — the flat look — still got a shadow on every landing card. It follows the control now.

A card's inner corner was rounder than the frame around it

Four corners were written as fixed measurements rather than taken from the corner setting, and two of them had been copied from a different corner scale. The most visible was a panel inset two pixels inside its own frame, drawing a 14-pixel inner corner inside an 8-pixel outer one.

  • Fixed all four onto the corner scale, so they follow the Corners setting like everything around them. The device mockups in the KYC form preview keep their own corners deliberately — those are drawings of a phone and a tablet, not controls.

One failed font download stopped the whole frontend from building

Building the frontend fetched 51 font files from Google — one for each alphabet of each of the ten typefaces the platform carries. A single one of those requests failing was enough to stop the build, and the error that came back named an internal module rather than the network, so nothing in it suggested checking the connection. A server behind a firewall, or in a region that blocks Google, could therefore never finish pnpm updator at all, and an install on a healthy network could still lose the occasional build to a momentary drop. The same failure was more stubborn during development, where it was remembered for the rest of the session: every page on the platform answered with a server error until the process was restarted, long after the network had recovered.

  • Fixed by shipping the font files with the platform. The build makes no outbound request for them, so it now completes on a server with no route to Google whatsoever.

Backend linting reported 36 errors that were not errors

  • Fixed the backend lint configuration never registering the plugin whose rules its own source refers to. Every deliberate exemption comment in the engine bridges — 36 of them — was reported as an error for naming a rule the linter could not resolve, which is the entire error count that package had.
  • Fixed the 36 exemption comments themselves, which had become inert. They are removed rather than left as decoration.

Frontend linting

  • Fixed every remaining lint error in the frontend: restricted imports on eight screens, seven empty exception handlers that swallowed errors with no explanation, four irregular whitespace characters, a lexical declaration inside an unbraced switch case, two components declaring an empty parameter pattern, and two passing children as a property instead of nesting them.

Removed

  • Removed GET /api/auth/login/chat, which created an account and returned a live session from four query-string parameters, with no captcha, no sign-in and — being a GET — outside the platform's rate limiter entirely. It had no callers anywhere in the platform, the add-ons, the tests or the documentation. See Upgrade Notes.
  • Removed the Proof-of-Work Captcha switch, replaced by Captcha Provider. The old key is still read as the source of your existing choice, so an install that deliberately turned the captcha off stays off across the update.
  • Removed the wrapper that turned every captcha outcome on the login and reset routes into one message, which reported a misconfigured secret key as a failure by the caller.
  • Removed the High Yield and Low Risk filters from the investment plans list. Neither was measured — the platform was reading a plan's advertised return and printing a risk classification from it, on a financial product, with no risk field anywhere in the schema. See Upgrade Notes.
  • Removed "Expected profit", "Total return" and a "protected by our security guarantee" line from the investment form, on a form that can open a position which settles at a loss. What replaces them is a projection signed by the plan's own settlement rule.
  • Removed the fabricated performance record from the investment landing page's illustration — "Realised performance", "Return +21.4%", "Benchmark +6.1%", "Max drawdown -4.2%", "Sharpe 1.84", "Trades 1,248" and "Confidence 87%", drawn as text inside the artwork. None of it came from anywhere. It survived the copy being corrected around it because nobody audits a picture for claims.
  • Removed thirty-nine hardcoded English sentences from the investment landing page — an entire trust row, two feature grids and every step of the walkthrough — on a platform that ships ninety languages. Along with them went the claims they were making: licences, independent audits, awards, and portfolio managers with decades of experience. What replaces them describes how the product actually works, which is both true and more use to a customer deciding whether to open a position.
  • Removed the "Analytics" and duplicate navigation entries from the old investment dashboard, whose destinations are now sections of My investments.
  • Removed the invented figures from the support screens — a "+12% this month" trend on the customer's ticket total, and a 4.9 rating and 2.5k resolved count shown under any assigned agent's name. See Upgrade Notes.
  • Removed the support section's own navigation bar, which duplicated a link the main menu already carried and highlighted two entries at once. See Upgrade Notes.
  • Removed the modal that raised a support ticket, replaced by /support/new.
  • Removed the Version Information panel from the add-on page. Two of its three tiles restated versions already in the page heading, and the third was the product id, which is not a version.
  • Removed the ten animated heading backgrounds — orbs, waves, particles, aurora, mesh, spotlight, ripples, geometry, glow and prism — together with the per-page colour, intensity and animation settings that selected between them. See Upgrade Notes.
  • Removed the animated particle canvas every one of those headings mounted. On the admin tables it was thirty-five particles redrawn continuously for as long as the page was open, on a screen whose job is to hold still while you read numbers; several of the other animations ran endless loops beside it. Nothing replaces them.
  • Removed the gradient poured through page titles. One hundred and thirteen titles used it, and a two-stop gradient clipped through text is one token edit away from a title that cannot be read at all — a fault this platform has measured before at barely above the contrast of the page behind it. A title is ink.
  • Removed fifteen declared-but-unused packages, including a prerelease build of a data-fetching library. See Upgrade Notes for the full list and the one case where it can affect a fork.
  • Removed the promotional line the configuration loader's new version writes to the error stream on every single call. It is switched off at all six places the platform loads configuration, so backend start-up, the migration runner and the frontend build are quiet again. If you monitor logs for error-stream output you may have seen this line appear and then stop — it was never an error.
  • Removed an unused import of an AI SDK from the KYC connection check, which imported the library and never called it.
  • Removed a second, identical declaration of the two default typefaces, which nothing on the platform referenced. It made every build download Geist and Geist Mono twice.
  • Removed a dependency override that pinned a package no longer present in the tree.

Every successful admin action was recorded as a failure

The audit trail at /admin/system/audit is the record of who did what — who approved the withdrawal, who ruled on the verification. Every entry in it was being written as an error, with the reason "Handler returned without sending a response", a step list that stopped part-way and a duration measured to the wrong instant. The actions themselves worked. Only the record of them was wrong, and it is the record an operator reaches for when they need to know what happened.

The console said the same thing in red, on requests that had succeeded, on every admin page — the dashboard, the extensions list, the site chrome picker, the operations inbox.

Both came from one omitted word. A request passes through a chain of gates — permissions, CSRF, rate limits, API-key verification — and each gate calls the next stage before the route itself runs. Four of them did not wait for it. A gate finished the moment the handler paused for its first database read, so the platform concluded the request was over while it was still being answered, marked it as unanswered, and wrote that verdict into the trail before the handler had returned. The tell was in the step list every time: it stopped exactly at the first database call, which is where the accounting overtook the work.

The permission gate is the one every admin route passes, which is why admin reads showed it constantly; the CSRF and rate-limit gates take that path only on requests that change something, which is how it reached the trail.

  • Fixed the four gates so each waits for the rest of the request it started. Admin actions are recorded as what they were, the console reports the real outcome and the real duration, and a request that genuinely goes unanswered is once again distinguishable from one that worked.