TAG Request Form
Public, unauthenticated QR-scan maintenance request form. Someone on the shop floor scans the sticker on a machine, lands on a page with the asset already identified, describes the problem, and submits — creating a TAG (The Asset Guardian) Request that maintenance triages into a work order.
Overview
| Resource | Value |
|---|---|
| Repo | ProgressiveSurface/tag-request-form |
| Azure | App Service ps-tag-request, resource group PS-WEBAPPS |
| Network | Public — no VNet, no private endpoint (documented exception) |
| Auth | None. Anonymous by design |
| Stack | Hono + TypeScript, Node 22, server-rendered HTML (no React, no SPA) |
| Writes to | TAG, via the Mobi connector platform |
The flow
QR sticker on the machine
→ procisely.com/EQ-1168
→ 302 (Procisely pattern rule)
→ tag-request-form /?equipment=1168
→ form, asset pre-identified
→ POST /submit
→ TAG Request (number from the TAG-REQ series)
→ maintenance triages it into a work order
Why it exists as a separate public app
Two other options were tried first and both fail on the same constraint — the person scanning is on a phone, possibly on cell data, and is not signing in:
- TAG Mobi cannot serve unauthenticated request forms. Its UI Builder produces org-visible pages that require a Mobi login.
- PSI Portal is private-endpoint only. A QR pointing at it fails for anyone not on PSI wifi or VPN.
So this is the one public surface, modelled on procisely-redirect so it inherits an already-reviewed public posture rather than inventing one.
Security posture
A documented public/anonymous exception under the Web App Compliance Standard, alongside procisely-redirect, psredbookphotos and ps-shipphotos.
Because anyone on the internet can reach it, the controls are on what it can do, not who reaches it:
| Control | Detail |
|---|---|
| Assets come from TAG | A request is only accepted for an asset TAG returns, and inactive assets are excluded. The roster is read from the system of record (listEquipment, cached 5 minutes) rather than a list in the code. |
| No read access | The app never reads from TAG — asset names are local config, so the key needs create-only reach. |
| Single write action | The only outbound call it can make is createRequest. |
| Rate limiting | 30 page views/min/IP; 5 submissions/10 min/IP. The primary volume control — production refuses to boot if it is raised above 60, and CI proves the guard fires. |
| Honeypot | Off-screen field; a hit returns a normal-looking confirmation so bots get no signal. |
| Bounded input | Every free-text field is length-capped and control-stripped. |
| CSP | default-src 'none', script-src 'none' — the page has no JavaScript at all — plus base-uri, form-action, frame-ancestors and img-src locked down. |
There is deliberately no CAPTCHA
An earlier revision used Cloudflare Turnstile. It was removed on purpose:
- It introduced a third-party vendor PSI does not otherwise use, for a single form.
- It made the posture worse where it counts most. The page needs zero JavaScript, so its CSP can say
script-src 'none'; Turnstile forces that open to admit a third-party script. A page that cannot execute script is a stronger guarantee than a bot check running inside one. - The threat model does not call for it — noindexed obscure URL, three valid equipment ids, per-IP rate limits, a honeypot, and human triage before anything happens.
If abuse ever materialises, escalate to Azure Front Door + WAF (Azure native, no new vendor) rather than reaching for a CAPTCHA again.
The reporter cannot set status, technician, work order, priority beyond the four real ranks, or any escalation field. Triage belongs to maintenance.
A TAG Request is a triage item, not a posted ERP transaction — the worst case for abuse is junk in a queue a human reviews, which is what makes this control set proportionate.
TAG integration
Requests are created through the Mobi connector platform, not Business Central directly. BC service-to-service would need an Entra app registration, a BC application user, a scoped permission set and a client secret to rotate; Mobi already exposes createRequest and needs only an API key. src/tag.ts is the only module that talks outward, so switching to the documented BC contract later is a one-file change.
Three gotchas, all verified against the live tenant
- Mobi returns HTTP 200 on business failures. Results are wrapped three deep — HTTP status, a JSON-RPC envelope, then
{ ok, data | error }inside an SSE frame. Checkingres.okalone silently swallows every error. - Never send
no. The connector schema marks it required; the live tenant assigns it from theTAG-REQnumber series when omitted. Computing it client-side would race whenever two people report the same broken machine — the normal case here, not the edge case. - Never send the legacy
priorityfield.priorityRankis what TAG’s own request form labels “Priority” (P0 - Critical…P3 - Priority 3). The separatepriorityenum is legacy, the UI never exposes it, andTagSetup.defaultWrkRequestPrioritysets it toCriticalfor every request including hand-created ones — so every Work Request prints as Priority: Critical. That is a tenant setting for the TAG administrator to change, not something this app should work around.
Adding a machine
Print a sticker. That is the whole procedure.
There is no list of machines anywhere in the app. The procisely pattern rule matches any equipment id, and the form resolves the asset from TAG at request time — so a machine added in TAG works the moment its sticker exists. No code change, no deploy.
The pilot boundary is simply which machines have stickers on them: currently 1168 (CNC Waterjet), 2358 (Robotic WJ Clean and Mill) and 2271. Any of the other ~49 active assets would work today if someone stuck a code on it.
This replaced a hardcoded allowlist in
src/assets.ts. That list existed to stop the endpoint being used to enumerate the asset register — a justification that expired once the base URL started showing a machine picker listing them all. Its only remaining effect was friction, and it had already drifted: it called 2271 “Line 6 Pre-Blast (Grit) Machine” where TAG says “JOB 2271 Automated Pass Thru Grit”.
QR codes and printing
Handled entirely by Procisely, which was built for QR stickers on machines.
The live rule (Pattern Rules → TAG maintenance request):
| Pattern | ^eq-(\d+)$ |
| Destination | https://request.procisely.com/?equipment={match:1} |
One rule covers every asset — including the ~49 without stickers yet — with no per-asset database row and nothing to configure in Procisely.
{match:1}, never{1}The redirect engine substitutes only
{slug},{match:N}and{segment:N}.{1}and named groups are left in the URL as literal text, so every scan would land on “machine not recognised”. Slugs are lowercased before matching but patterns compile case-insensitively, so^eq-(\d+)$still matches a sticker printedEQ-1168.
Getting the sticker artwork
A URL, not a download:
https://procisely.com/qr/EQ-1168?top=Scan+to+report+a+problem&bottom=CNC+Waterjet+%C2%B7+1168
That’s the printable label — QR plus caption above and below.
For a batch, use the QR Generator’s “From a pattern rule” panel: pick the rule, paste a column of equipment ids, press Print stickers. You get a PDF at four per Letter sheet, with each machine’s name and number pulled from TAG so the captions match the system of record rather than whatever was retyped at the printer.
The label is attached to the asset in TAG
Each pilot asset carries the label URL as a URL attachment (isUrl: true, printOnWorkOrder: true), so:
- anyone can open the asset in TAG and reprint the exact sticker
- the artwork always renders current — change a caption and every asset follows, with nothing to re-upload
The sticker code also sits on the asset record itself: field17 = EQ-<id>, shown on the asset card under Asset Label Caption, with the full URL in text1. TAG is therefore the system of record for what each sticker encodes — useful when one gets scratched off a machine.
The
field17caption could not be renamed via the API: Business Central raises a “Changing Lookup Setting” confirmation dialog that an API call cannot answer. It shows as the generic Field 17 until someone renames it in the TAG UI.
Telemetry
Scan counts come free via GET /api/procisely/analytics/clicks. Clicks are logged per slug for pattern-rule matches too, so this works without any redirect records — useful pilot signal on whether the stickers are actually being used. Generating a label does not log a click.
Development
npm (not pnpm — matching procisely-redirect). The Mobi client is stubbed throughout, so the suite never touches TAG.
npm install
npm test # no network
npm run test:coverage # thresholds bite
npm run dev # http://localhost:8080/?equipment=1168ci.yml gates PRs and branch protection requires it: build, test, coverage thresholds, a boot smoke-test, a scan-path smoke-test, and a check that the app refuses to boot in production with rate limiting disabled. Deployment is GitHub Actions on push to main via the self-hosted psi-internal runner with az login --identity.
Related
- tag-mobi — the CMMS this files requests into
- procisely — the QR/short-link platform in front of it
- webapp-compliance-standard — the public/anonymous exception profile