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

ResourceValue
RepoProgressiveSurface/tag-request-form
AzureApp Service ps-tag-request, resource group PS-WEBAPPS
NetworkPublic — no VNet, no private endpoint (documented exception)
AuthNone. Anonymous by design
StackHono + TypeScript, Node 22, server-rendered HTML (no React, no SPA)
Writes toTAG, 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:

ControlDetail
Assets come from TAGA 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 accessThe app never reads from TAG — asset names are local config, so the key needs create-only reach.
Single write actionThe only outbound call it can make is createRequest.
Rate limiting30 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.
HoneypotOff-screen field; a hit returns a normal-looking confirmation so bots get no signal.
Bounded inputEvery free-text field is length-capped and control-stripped.
CSPdefault-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

  1. 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. Checking res.ok alone silently swallows every error.
  2. Never send no. The connector schema marks it required; the live tenant assigns it from the TAG-REQ number 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.
  3. Never send the legacy priority field. priorityRank is what TAG’s own request form labels “Priority” (P0 - CriticalP3 - Priority 3). The separate priority enum is legacy, the UI never exposes it, and TagSetup.defaultWrkRequestPriority sets it to Critical for 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+)$
Destinationhttps://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 printed EQ-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 field17 caption 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=1168

ci.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.