我們逆向分析 ELMA 365 的「Check」按鈕,並將 widget 程式碼納入 Git
We reverse-engineered a low-code platform's "Check" button and put its widget code in git
團隊逆向分析 ELMA 365 的「Check」按鈕,重建 widget 腳本的編譯流程,讓程式碼可在本機檢查並納入 Git。團隊以 74 個 widget 比對本機與伺服器編譯結果,補齊型別定義規則後,兩者輸出逐字節吻合。伺服器的 Check 未能識別首次提交的新 server function,工具會提示這種情況。
Low-code platforms promise you will barely write code. That holds for a few months. Then you have dozens of forms, several people editing them, and someone who "just tweaked a script in production". It turns out you are doing software development after all, only without git, without review and without a way to roll back.
I lead a business applications team in a holding company. We build modules for contracts, counterparties, correspondence and powers of attorney on ELMA 365, a low-code BPM platform popular in Russian-speaking markets. Part of the code is written by AI coding agents. This post is about the pipeline we built to get widget code out of the browser and into a repository, and the traps we found on the way.
The starting point
In ELMA 365, almost everything is configured in a web designer: forms, fields, processes, permissions. Widgets carry TypeScript scripts. Client scripts run in the user's browser, server scripts run on the platform. You write them in a code editor inside the designer, press "Check", press "Publish", done.
That works for one developer. For a team where AI agents write code, it is a dead end. An agent needs a file. A reviewer needs a diff. A manager needs to know what changed on Tuesday.
The platform ships a CLI that exports a solution to files. Export works well: hundreds of files, forms, processes, permissions and scripts, all ready for git. Import is the problem. Label and form changes went back fine. As soon as a widget script changed, the import failed. We reproduced it on several platform builds. The official position is clear: widget scripts are written in the designer, and there is no documented path around it.
We could have accepted that. Instead we went to see what the button actually does.
Stopgap: a robot in the designer
While we investigated, work had to ship. So the first solution was an honest hack: a headless browser that logs in, opens the widget, pastes the code, and clicks Save, Check and Publish.
Every step cost an evening. The code editor auto-closes brackets, so typing code character by character produces garbage. You paste through the clipboard instead. You cannot verify the paste by reading the hidden textarea either, because it only mirrors the visible part of the file.
The worst surprise came after a platform upgrade. In the new UI, the Publish button stayed enabled even when Check had reported compile errors. A robot that looked at button state could have published broken code. We changed it to read the compiler's response instead of the UI.
One rule from that period is now a team policy: automate the UI only when there is no CLI or API. Browser automation is fragile, slow and breaks with every redesign. It is the last resort, not the first.
What "Check" really runs
Here is the chain on the server. The button sends the widget to the widget service. That service puts a "compile" job on a RabbitMQ queue. A Node.js worker picks it up. Inside the worker is a plain TypeScript compiler.
The interesting part happens before compilation. For each widget, the platform generates a type definition file describing the world the script lives in: the form's context and fields, the fields of the app the form is bound to, the server functions, the platform SDK. That is how the compiler knows your contract has a counterparty field and does not have counterpartyy.
Then it is a normal TypeScript program of two files: the generated definitions and your script. Any diagnostic rejects the script and nothing is stored. Otherwise the code is emitted. Client code gets wrapped in a function roughly like this:
export default function (Context, ViewContext, Server, System): any {
// your code, imports stripped
async function onInit() { /* ... */ }
async function onSave() { /* ... */ }
return { onInit, onSave };
}
Every top-level function is returned so the platform can call it. Server code is transpiled in place. The platform also stores a description of each function's parameters, and those descriptions contain the literal string "[object Object]" where types should be, an artifact of how they are built from the syntax tree. Harmless, but if you want byte-identical output, you have to reproduce quirks like that too.
Running the compiler locally
If the compiler is ordinary, you can run it yourself. We did.
The per-widget type definitions come from the same request the designer itself makes, with a normal login. No tokens are pulled out of the browser. The compiler is the public typescript package pinned to the exact server version, with the same options. A test fails if the version drifts. The wrapper and emit steps were ported line by line.
Then we had to prove it was the same compiler, not a similar one. We took the whole contracts module, all 74 widgets, compiled each one locally, sent the same sources to the server's own check, and compared byte by byte.
The first run showed type errors in ten widgets. They were not bugs in the code but gaps in our understanding of how the server builds the type definitions. For example, for a form bound to an app, fields come from the app, and the form's own fields arrive as an extra context. Each rule cost one failing widget. Once the rules were complete, all 74 matched. Deliberately injected errors (a string assigned to a number, a call to a missing server function, a wrong return type) were reported by both sides at the same position.
Seven stock widgets stored compiled code in an older wrapper format. When we sent them to the server compiler, it produced our output, so that code was simply built by an older platform version.
We also found a blind spot in the server's own Check: it builds the list of server functions from what is already saved, not from the submitted code. A brand-new server function looks like an error during the first check, even though it publishes and runs fine. Our tool recognizes that exact pattern and says so.
"There is no signature"
Why couldn't a package with a tiny manual edit go back in? Our first guess was a signature.
It wasn't. We changed one letter in a panel title. The edited package passed server validation. The only hashes in the package protect the "paid" and "locked" flags, so a paid solution cannot be flipped to free. Form and script content is not covered. We even flipped a character in the company identifier inside the package. Nothing changed.
The real gate is version identity. Every solution has an authoring server, where it was created, and target servers, where it is installed. On a target, import gives the widget a fresh internal ID and new versions land cleanly. On the authoring server, import matches the existing widget and tries to write a new version whose ID is derived from the version number. That record already exists, so the server reports a collision.
So: the server that authored a solution refuses a new version of its own widget from a file. The same package installs fine where the solution is merely installed. That is not tamper protection. It is an identity rule, and it should shape how you arrange your environments.
The silent success trap
The next traps don't fail. They pretend everything is fine.
Each object in the package carries a version history. If you change an object without adding a new history entry, the import sees a match with the server and silently skips it. Exit code zero, task successful, nothing applied. Pre-validation doesn't help: it is equally happy with a package that will apply, one that will be skipped, and one that will collide.
Worse, the server does not recompile on import. It stores the compiled code from the package as-is. Fix the source but skip the compile, and the designer shows your fix while the form runs the old code.
Two rules came out of this:
- After every install, export again and diff against what you sent. Exit codes are not proof.
- Run the local compiler in verify mode in CI. It writes nothing and fails if any widget's compiled code does not match its source.
The vendor update that would have deleted our work
A supplier sent a new build of the contracts module to install over ours. Before installing, we exported the live solution and diffed the forms. The sidebar of the contract card had eleven blocks; so did the update, but different ones. The block containing our custom document-list widget was simply not there. Installed as is, the customization would have quietly vanished from the form.
No bad intent: the supplier didn't know about our change. But the rule is now fixed. Before installing any update package, diff the live forms. An update is a full new version of a form, not a patch.
The pipeline today
export solution from the environment
|
edit .ts source in the repo (human or agent)
|
compile locally against that environment's types
|- type error -> nothing written, fix it
|- ok -> compiled code regenerated, version entry added
|
review the diff: source + compiled code
|
install on the test server
|
export again, compare with what was sent
|
commit what is actually live
Before installing, the tooling snapshots the target. Rollback goes through files too, with a twist: a raw old snapshot is refused because its version history is older than the server's. So rollback takes the content from the snapshot, the history from the server, adds a new entry, and verifies by exporting again.
Environments have roles. A sandbox we fully control for experiments. A shared development server for business work. Then test and production, where a partner company transfers changes through its own process. Production is read-only for our agents. Before anything goes to the shared server, a candidate is installed on an isolated environment and the whole affected business route is exercised with real form buttons. A successful publish response only proves the request arrived.
Finished changes go into a simple register. One card per change: what changes, how to check it on test, how to confirm it on production read-only. Statuses: waiting for transfer, on Test, on PROD, or cancelled. Boring, and it stops finished work from sitting forgotten for two weeks.
CI for our own tooling
Around this pipeline we built an internal team web service, our Wiki. Agents work on it too, so its CI is stricter. Every push and pull request runs the tests, then a set of real-browser checks walks through the pages, and their screenshots are kept for a week.
The production update command only runs from a clean main branch and ships an archive of exactly the committed revision. It builds and tests on the host while the old service keeps running, and stops the old one only after the new one passes. It takes a backup, keeps the previous container for rollback, checks health and HTTPS afterwards, and restarts the old container if activation fails. It connects only to a host whose key is already known and refuses unknown or changed keys. No secrets go into the repo, the command line or the release archive.
What I took away
Low-code is development. Once you have more than one developer, or a single AI agent, you need files, diffs, review and repeatable installs.
"No documented path" doesn't always mean "no path". Sometimes it means looking at what the button does. Behind ours was an ordinary TypeScript compiler, and we could reproduce it honestly, without bypassing any protection.
And the most dangerous failures are the quiet ones. We no longer trust buttons or exit codes. We trust the re-export and the diff.
來源:DEV Community · dev.to