Procisely
PSI-owned URL shortener and asset link management platform. Provides persistent, branded short URLs that resolve to customer-facing destinations. Primary physical interface is a QR code sticker affixed to each shipped machine.
Overview
Procisely.com is a two-component platform:
-
Redirect Engine — A public-facing Node.js service at
procisely.comthat resolves short URLs to destinations via 302 redirects. Customers scan QR code stickers on machines and are routed to the appropriate portal, documentation, or support page. -
Admin Interface — Integrated into the PSI Portal at
portal.progressivesurface.com/procisely/. Provides redirect management, pattern rules, templates, QR code generation, bulk import, and analytics.
| Resource | URL |
|---|---|
| Production | https://procisely.com |
| Admin UI | https://portal.progressivesurface.com/procisely |
| Redirect Engine Repo | ProgressiveSurface/procisely-redirect |
| Admin Code | Part of ProgressiveSurface/psi-portal |
| PRD | C:\Users\AMD\OneDrive - Progressive Surface\Projects\URL Redirect\Procisely-PRD-V1.md |
How It Works
Redirect Flow
Customer scans QR sticker on machine
→ procisely.com/S9820
→ Redirect engine looks up slug "s9820"
→ Match: SPN pattern rule (^S\d{4}$)
→ 302 → portal.progressivesurface.com/assets/s9820
→ Click event logged (slug, timestamp, user-agent, IP hash)
Resolution Precedence
- Exact slug match (Redirects table) — highest priority, allows per-slug overrides
- Pattern rules (PatternRules table) — regex-based, evaluated in priority order
- Default redirect —
https://www.progressivesurface.com
Rule Types
| Type | When It Runs | Requires DB Record? | Example |
|---|---|---|---|
| Exact redirect | Redirect time | Yes | support → progressivesurface.com/support |
| Pattern rule | Redirect time | No — matches via regex | ^S\d{4}$ → portal…/assets/{slug} |
| Rule template | Creation time only | Produces records | Admin shortcut for batch creation |
Architecture
Isolation Model
The redirect engine and admin interface are fully isolated. A compromise of the public service cannot reach internal resources.
| Layer | Redirect Engine | Admin (Portal) |
|---|---|---|
| Compute | Dedicated App Service Plan | Existing Portal App Service |
| Network | Public, no VNet | Private endpoint |
| Identity | Own managed identity | Portal’s managed identity |
| SQL Role | procisely_redirector (SELECT + INSERT only) | procisely_admin (full CRUD) |
Technology Stack
| Component | Technology |
|---|---|
| Redirect Engine | Node.js 22 LTS + TypeScript + Hono |
| Admin UI | React 19 + TypeScript + Vite + TailwindCSS (in Portal) |
| Admin API | Node.js + Express (Portal backend routes) |
| Database | Azure SQL (Procisely on procserv-proddata) |
| QR Generation | qrcode + sharp + archiver (server-side) |
| Auth | Entra ID / MSAL (admin only) |
| Hosting | Azure App Service (Linux) |
Infrastructure
Azure Resources
| Resource | Name | Resource Group | Details |
|---|---|---|---|
| App Service Plan | asp-procisely-redirect | PS-WEBAPPS | B1 Linux, dedicated (compute isolation) |
| App Service | procisely-redirect | PS-WEBAPPS | Node 22 LTS, Always On, public |
| Azure SQL Database | Procisely | ProcServices-Prod-Data | Basic (5 DTU) on procserv-proddata |
| DNS Zone | procisely.com | PS-RG-01 | Azure DNS |
| SSL Certificate | procisely.com | PS-WEBAPPS | Azure-managed (GeoTrust), expires Oct 2026 |
| Policy Exemption | procisely-redirect-public-access | PS-WEBAPPS | Exempts redirect engine from “Deny App Service Public Network Access” policy |
| Application Insights | psi-webapps-insights | PS-WEBAPPS | Shared with other PSI web apps |
App Settings (Redirect Engine)
| Setting | Value |
|---|---|
| SQL_SERVER | procserv-proddata.database.windows.net |
| SQL_DATABASE | Procisely |
| SQL_AUTH_TYPE | azure-active-directory-default |
| WEBSITES_PORT | 8080 |
| NODE_ENV | production |
| APPLICATIONINSIGHTS_CONNECTION_STRING | (from psi-webapps-insights) |
DNS Records (procisely.com)
| Record | Type | Value |
|---|---|---|
| @ | A | 52.162.107.11 (App Service IP) |
| www | CNAME | procisely-redirect.azurewebsites.net |
| asuid | TXT | Domain verification hash |
Domain registered at Network Solutions, nameservers delegated to Azure DNS (ns1-05.azure-dns.com through ns4-05.azure-dns.info).
Database Schema
Database: Procisely on procserv-proddata.database.windows.net
Tables
| Table | Purpose | Key Columns |
|---|---|---|
| Redirects | Slug → destination mapping | Slug (PK), DestinationUrl, RedirectType, Category, IsActive, HttpStatusCode |
| ClickEvents | Per-click telemetry | Id (identity), Slug, ClickedAt, UserAgent, RemoteIpHash |
| RuleTemplates | Creation-time helpers | TemplateId (PK), SlugPattern, DestinationPattern |
| PatternRules | Runtime regex rules | PatternRuleId (identity), Pattern, DestinationTemplate, Priority (unique) |
SQL Roles
| Role | Permissions | Used By |
|---|---|---|
procisely_redirector | SELECT Redirects + PatternRules, INSERT ClickEvents | Redirect engine managed identity |
procisely_admin | Full CRUD on all tables | Portal managed identity |
Schema source: procisely-redirect/sql/schema.sql
Security
Redirect Engine Hardening
- One route:
GET /{slug}plusGET /health. No admin routes, no Swagger. - Slug sanitization: 5-step pipeline — URL decode, null byte rejection, CRLF rejection, alphanumeric+hyphen allowlist (max 128 chars), lowercase normalization.
- Parameterized SQL queries only — slug never interpolated into query strings.
- No user input in responses — returns only
302 + Locationheader. No HTML body, no error details. - Security headers on all responses: HSTS, CSP (default-src ‘none’), X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy no-referrer.
- Rate limiting — in-memory per-IP rate limiter.
- Click event IP privacy — SHA-256 hash only, full IP not retained.
- Fire-and-forget click logging — click recording never blocks redirect response. Only logs clicks for actual matches (exact or pattern) — default fallback redirects (bot probes, unknown slugs) are not logged.
Pattern Rule Safety
- Regex patterns are admin-authored only (never from public input).
- Slug input bounded to 128 chars (limits ReDoS surface).
- Invalid regex patterns are silently skipped at runtime.
- Regex validation at creation time in the admin API.
Admin Features (Portal Integration)
All admin features are at portal.progressivesurface.com/procisely/*, behind Entra ID auth and private endpoint.
Pages
| Page | Route | Purpose |
|---|---|---|
| Dashboard | /procisely | Stats, default behavior config, active pattern rules, recent activity |
| Redirect List | /procisely/redirects | Filterable table, inline toggle, bulk ops, CSV export |
| Redirect Editor | /procisely/redirects/new or /:slug/edit | Create/edit with template or custom mode |
| Bulk Import | /procisely/bulk-import | CSV upload, template import, combined onboard + QR flow |
| QR Generator | /procisely/qr | Single/batch QR generation with logo overlay, plus a “From a pattern rule” panel — see below |
| Template Manager | /procisely/templates | CRUD for creation-time templates |
| Pattern Rules | /procisely/patterns | Regex rule management with live tester |
| Analytics | /procisely/analytics | Click trends, top redirects, category breakdown |
QR Generator: printing pattern-rule assets
The generator lists redirect records. Pattern rules deliberately have none — so the assets they cover (usually the majority) were unprintable, and the workaround was to create records purely so the picker could see them, which inverts what pattern rules are for.
The “From a pattern rule” panel closes that: pick the rule, type or paste the values (newline / comma / tab separated, so a spreadsheet column pastes straight in), and every derived slug is validated against the rule’s real regex with its resolved destination shown. Values that wouldn’t route are flagged and excluded rather than silently printed — a sticker that dead-ends gets discovered by someone standing at a broken machine.
A regex can’t be reversed in general, so slug derivation is best-effort and validation is the guarantee.
Print stickers builds the sheet directly: four labels per Letter page, each carrying the machine’s name and number from TAG (GET /api/procisely/assets) so captions match the system of record. The bold line is editable and defaults to “Scan to report a problem”.
The TAG lookup is read-only by construction — listEquipment is the only action and confirmExecution is never sent, so it cannot write. It caches for 5 minutes and falls back to the last good roster on failure, because a stale name on a label beats a failed print run. Without MOBI_API_KEY / MOBI_CONNECTOR_INSTANCE_ID the panel still works and prints the code instead of the name.
The
4uppreset is 3.9 × 4.5in, and the width is load-bearingWith the default 0.25in margins and 0.1in gap, two columns need each label at or under 3.95in. At 4.0in the grid silently collapses to one column and prints two per page — no error, just wrong.
computeGrid()is exported and tested against the real preset for exactly this reason.
API Endpoints
All endpoints at /api/procisely/*, authenticated via Entra ID bearer token.
| Method | Path | Purpose |
|---|---|---|
| GET | /health | Health check (no auth) |
| GET/POST/PUT/DELETE | /redirects/* | Redirect CRUD + bulk + from-template |
| GET/POST/PUT/DELETE | /templates/* | Template CRUD |
| GET/POST/PUT/DELETE | /patterns/* | Pattern rule CRUD + test + reorder |
| GET | /analytics/* | Click analytics + top redirects |
| GET | /assets | TAG asset roster (read-only) for label captions |
| GET | /redirects/:slug/qr | Single QR generation — accepts any slug; no database lookup |
| POST | /redirects/batch-qr | Batch QR ZIP download — likewise, arbitrary slugs |
| POST | /redirects/labels-pdf | Print-ready QR label sheet (QR + readable text, N-up) |
| POST | /redirects/bulk-onboard | Template import + QR download in one flow |
Seed Data
Default Templates
| Template | Slug Pattern | Destination | Category |
|---|---|---|---|
| Asset Customer Portal | {serial} | portal.progressivesurface.com/assets/{serial} | asset |
| Recalibration Request | recal-{serial} | portal.progressivesurface.com/recalibration?asset={serial} | support |
| Documentation Library | docs-{serial} | docs.progressivesurface.com/machines/{serial} | asset |
| Campaign Landing Page | {auto} | (user-specified) | campaign |
| Internal Tool Link | {slug} | (user-specified) | internal |
Default Pattern Rule
| Name | Pattern | Destination | Priority |
|---|---|---|---|
| SPN Asset Portal | ^S\d{4}$ | portal.progressivesurface.com/assets/{slug} | 10 |
This rule means every PSI serial number (S + 4 digits) automatically redirects to the asset portal without creating individual redirect records.
CI/CD
Redirect Engine
| Setting | Value |
|---|---|
| Trigger | Push to main |
| Runner | psi-internal (ps-cicd-runner, Ubuntu) |
| Auth | az login --identity (managed identity) |
| Deploy | az webapp deployment source config-zip |
| Tests | 70 unit + integration tests (Vitest) |
Portal (Admin UI + API)
Deployed as part of the standard Portal CI/CD pipeline. Push to master triggers build + deploy.
Additional tests: 30 API route tests (Vitest + Supertest) via npm run test:api.
Testing
| Suite | Count | Framework | Command |
|---|---|---|---|
| Redirect engine unit tests | 62 | Vitest | cd procisely-redirect && npm test |
| Redirect engine integration | 8 | Vitest + Hono app.request() | Same |
| Portal API route tests | 30 | Vitest + Supertest | cd psi-portal && npm run test:api |
| Total | 100 |
QR Code Specifications
| Setting | Value |
|---|---|
| Content | https://procisely.com/{slug} |
| Formats | PNG (raster) and SVG (vector) |
| Sizes | 128px to 2048px (PNG), scalable (SVG) |
| Error correction | Level M (15%) default, Level H (30%) with logo |
| Logo overlay | White rounded knockout plate with the PSI mark on it, ~36% of the QR width and proportioned to the mark. Covers ~8% of the code’s area against Level H’s ~30% budget. |
| Print quality | 300 DPI minimum for 1” stickers |
| Batch export | ZIP archive with filenames matching slugs, or a 4up label sheet (four per Letter page) |
The logo used to be composited straight onto the code
It was chroma-keyed to transparency so only the letterforms obscured modules — “no rectangular halo”. The result read as a logo lost in noise. It now clears a quiet area, which is what every polished QR generator does, and Level H error correction pays for it.
The scannability claim is tested, not assumed.
tests/server/procisely/qrDecode.test.jsdecodes the generated image withjsqrand asserts the URL survives — with and without the logo, at 256/512/1024, and with a long slug where the code is densest and the plate covers proportionally more modules. A QR that looks right and doesn’t scan fails silently, in a plant, in the hands of someone trying to report a fault.If you raise
PLATE_RATIOfor looks, those tests are what stop you shipping an unscannable sticker.
Public QR endpoint — GET /qr/:slug
A QR code you can reach by URL, served by the public redirect engine. No authentication, no portal, no download step.
https://procisely.com/qr/EQ-1168 PNG
https://procisely.com/qr/EQ-1168?size=1024 larger (capped at QR_MAX_SIZE)
https://procisely.com/qr/EQ-1168?format=svg vector, for real label stock
Printable labels
?top= and ?bottom= wrap the code in a label with text above and below — a bare QR on a machine tells nobody what it’s for:
https://procisely.com/qr/EQ-1168
?top=Scan+to+report+a+problem
&bottom=CNC+Waterjet+%C2%B7+1168
Captions are composed as SVG and therefore force SVG output even if format=png was requested — returning a PNG silently missing the requested text would be worse than ignoring the format hint. Captions are XML-escaped and capped at 60 characters.
Why this exists separately from the portal generator
The portal’s QR page is Entra-authenticated behind a private endpoint, so its images can’t go in an <img> tag, a Word document, a label template, or an asset record in another system. This endpoint can — it is the primitive; the portal page is the convenience layer.
In practice: TAG asset records attach this URL directly (TAG attachments support isUrl), so the sticker artwork lives on the asset and always renders current. Nothing to re-upload when a caption changes.
Behaviour worth knowing
| Behaviour | Why |
|---|---|
| The slug is not resolved first | Pattern rules match at request time and have no records. Requiring a code to “exist” would make exactly the assets that most need printing unprintable. |
qr is a reserved prefix | Registered before the catch-all /:slug, which would otherwise match /qr/EQ-1168 as slug qr. No redirect may use qr as its slug. |
| Size is capped | Public and unauthenticated — an uncapped size is CPU amplification. Per-IP rate limiting applies on top. |
| Cached 30 days | A code for a slug is deterministic, so repeat requests are free. |
Encodes QR_BASE_URL, not the request host | A code minted via the azurewebsites.net hostname must still point at procisely.com. |
| No click is logged | Printing a label is not a scan; counting it would inflate the analytics that show whether stickers are actually used. |
| No logo overlay | That needs sharp, a heavy native dependency, on a small internet-facing service. Logo’d codes stay in the portal. |
Future Roadmap
| Feature | Priority | Trigger |
|---|---|---|
| Business Central integration | High | BC go-live — auto-create asset links from shipped machines |
| Geolocation analytics | Medium | When click volume justifies GeoIP cost |
| Dynamic customer routing | Medium | Portal queries customer from serial |
| Bot detection | Low | Click analytics reliability |
| Link expiration | Low | Campaign use cases grow |
| CPQ integration | Medium | Auto-generate links at order time |
| Per-redirect 301 option | Low | SEO benefit for permanent redirects (schema field exists) |