Case study · 03 / 11
When the platform has no server, write the rules twice
A case study — SharePoint Dynamic Forms & Workflow Engine
Summary
| | | |---|---| | **Problem** | Every new SharePoint business form meant another custom-coded web part, and every approval chain meant another bespoke workflow. | | **Constraint** | SharePoint Online runs no server-side code. Anything the browser decides can be bypassed by calling the REST API directly — so the browser cannot be the authority on who may approve what. | | **Solution** | Nine SPFx web parts that render, validate and route forms from a JSON schema, with a workflow engine whose browser half is deliberately advisory and whose authority lives in a Power Automate flow. | | **Outcome** | Forms are production-ready. The workflow engine is not — not because of a known defect, but because no part of it has ever executed. | | **Role** | Architecture, all nine web parts, schema design, workflow engine, code review, production-readiness audit. | | **Stack** | SPFx 1.22.1 · React 17 · TypeScript · Fluent UI v8 · PnPjs 4.17 · jsPDF · SheetJS |
1. The problem
A SharePoint estate accumulates forms. Each one is a list plus a custom form plus, eventually, an approval chain. Built individually, each is a small project: a developer, a build, a deployment, a version to maintain. Ten forms is ten codebases that all do the same four things slightly differently.
The goal was to make a form a configuration artefact — a JSON schema declaring sections, fields, rules and routing — so that adding a business form stops being a development task.
2. The constraint that shaped everything
SharePoint Online runs no server-side code. Anything it decides in the browser can be bypassed by calling the REST API directly.
This is the whole design problem. A forms engine is easy. A *workflow* engine on a platform with no server is not, because a workflow engine's entire job is to decide who is allowed to do what — and the browser cannot be trusted to make that decision about itself.
Any approval button that computes "this user may approve this item" in TypeScript is decoration. The user's own token can call the REST API directly and set whatever the button would have set.
3. Architecture
Nine web parts and an application customizer, across roughly 22,500 lines of TypeScript. The schema layer declares sections and fields, each with a controlType bound to one of thirteen renderers — text, multiline, date, checkbox, choice, people picker, lookup, attachments, number, rich text, managed metadata, colour picker — plus childCollections and documentCollections for one-to-many grids and upload zones.
Four provisioned lists carry the workflow: definitions, requests, tasks and an activity log.
4. The interesting decisions
4.1 Routing logic implemented twice, on purpose
The resolution to the no-server problem is to make the browser's message carry no authority:
The browser sends { targetList, itemId, action, comment } and nothing else. Nothing in that payload can influence the outcome, so there is nothing worth forging.A Power Automate flow — running as a service identity, outside the user's reach — receives that payload, independently determines who the approver should be, checks that the caller is that person, and acts. The browser cannot lie about its own authority because it is never asked.
The cost is stated in the design document more candidly than most engineers would:
The routing rules end up expressed twice — once in TypeScript for the UI, once in flow actions for the authority. engine.ts is the written specification of what the flow must do. When the two disagree, the flow wins and the engine is the bug.Duplicated logic is normally a defect. Here it is the design, because the two copies serve different masters: one decides what to *show*, the other decides what to *permit*. Naming which copy is authoritative — and stating that the other one is by definition wrong when they diverge — is what stops the duplication rotting.
4.2 A validation system with one rule
Validation is modelled in two places deliberately: per-field visibility and requirement through a field-rules module (six operators, three outputs — hidden fields, disabled fields, conditionally required fields), and cross-field validation through the schema's validations block.
That second one currently supports exactly one rule type: date comparison, with four operators.
Worth naming rather than hiding, because it is the gap between what the schema *advertises* and what it *does*. A schema that declares a validation system implies an extensible one; a consumer reading the guide would reasonably expect to express "if A is set, B must be greater than C." They cannot. That is a roadmap item honestly labelled, not a feature.
4.3 Two bugs that made features invisible
Both found in review, both the same shape — a rule that silently removed the entire user interface for a class of user.
Every generated approval was created locked. All three web parts hid workflow buttons on locked items. Net effect: *the line manager could never act on a pending item.* The workflow was correct; the item was simply unreachable through the only interface that offered it.
Approver gating compared group names case-sensitively. "CEO approvers" did not match "CEO Approvers", so every button was hidden from every CEO. No error, no log — the buttons just weren't there, and the person affected had no way to know a button was supposed to exist.
The class is worth naming: *authorisation bugs that fail by hiding.* A permission check that throws gets reported in minutes. One that renders nothing gets reported as "the system doesn't do that", if at all.
4.4 The production-readiness pass
A dedicated audit produced the strongest artefact in the repository. Four findings worth carrying:
The list-view threshold refuses rather than degrades.
SharePoint refuses to evaluate a filter on an unindexed column at all — it throws rather than slowing down… long after the change that caused it.
The failure arrives detached from its cause, once the list crosses 5,000 items, on a query that worked yesterday.
A permission-downgrade ordering bug. Granting the new permission failed, the failure was swallowed, and the removal of the old one still ran — leaving the user with neither. Ordering plus a silent catch, which is the same failure family as the save-before-mark-synced bug in the timesheet project.
A stored-script XSS path, described precisely enough to convey the severity: *a requester could make an approver's browser POST their identity to any host.* Attacker-supplied content rendered in the browser of exactly the person with the most authority.
Lazy-loading the taxonomy picker cut the create bundle from 1.1 MB to 928 KB by deferring a 150 KB dependency most users never trigger.
5. Outcome
The forms half is production-ready: nine web parts, thirteen field renderers, twelve sample schemas, a 1,240-line schema guide.
The workflow half is not, and the repository says so in terms worth quoting because so few projects do:
The forms are production-ready. The workflow engine is not — not because of a known defect, but because no part of it has ever executed.
The single generic Power Automate flow — the authority in the whole design — has not been built. Every workflow action therefore queues as Pending. The engine that decides what the flow must do exists; the flow does not.
Also recorded: no end-to-end or component tests (78 pure-logic assertions across four files); the My Work view reads up to 2,000 tasks and filters client-side because SharePoint cannot filter multi-value person columns; and a genuine soundness gap — branch fields are requester-declared, so *"the engine enforces a rule against data the person being checked supplied."*
6. What I'd take from this
Know what your platform can enforce before designing around it. Half of what a workflow engine does is authorisation, and SharePoint Online gives the browser no way to do it. Designing the payload so it carries no authority is the honest answer; pretending the client-side check is security is the common one.
When you must duplicate logic, name the authority. Two implementations of the same rules is a maintenance liability unless it is written down which one is correct and which one is, by definition, the bug when they disagree.
Authorisation bugs that hide the UI are the hardest to find. Two here — locked-by-default and a case-sensitive group comparison — each removed a feature completely for a whole role, silently. Neither throws. Both were found by asking who *should* see a button and checking they do.
A schema is a promise. Declaring a validation block that supports one rule type sets an expectation the implementation does not meet. Either narrow the schema or widen the implementation, but do not let the documentation imply the second.