PSI Portal

The PSI Portal is the central landing page and application launcher for all PSI web applications.

ResourceURL
Productionhttps://portal.progressivesurface.com
RepositoryProgressiveSurface/psi-portal
Azure Apppsi-portal.azurewebsites.net

Features

Application Tiles

The home page is a single flat, alphabetized tile grid (my-apps style). As of 2026-07-16 there is no “Internal Tools” / “Web Applications” section split and no page-level header — the grid starts immediately under the topbar. Each tile shows:

  • App icon and name
  • Description
  • Status indicator (Live, Beta, Coming Soon)
  • Category badge (Engineering, Quality, Data & Integration, Administration, IT Operations, Finance)

External web apps carry an external-link marker and open in a new tab; portal sub-apps navigate in-app. Alpha-gated tiles appear only for allowlisted users.

System Status Pill

A compact status pill (pulsing green dot + “Operational”) sits on the right side of the topbar, visible on every page. It replaced the home-page quick-stats KPI banner (live-app count, ProApps count, REST API count), which was removed 2026-07-16 as noise.

Desktop Apps Section

Information about PSI ProApps with link to documentation for users looking for desktop applications.

Technology Stack

ComponentTechnology
FrameworkReact 19
LanguageTypeScript
Build ToolVite
StylingTailwindCSS v4
IconsLucide React
API RuntimeNode.js / Express

Production Configuration

SettingValue
Azure App Servicepsi-portal
Resource GroupPS-WEBAPPS
App Service Planasp-erp-migration-tool
RuntimeNode 24 LTS (Linux)
Custom Domainportal.progressivesurface.com
Private Endpoint IP10.160.0.6
Always Onenabled (2026-08-11 — cold boots read as “slow fleet list”)
Fleet connector cadenceIntune 60 min · Meraki 60 min · Entra directory 12 h · Dell 24 h; all run ~1–2 min after any container start; manual sync on IT Ops Overview
Entra App ID7f929c7f-2483-4206-93b6-11225e07ca85

Auth Profile

psi-portal is an approved client/app-layer auth application under the PSI web app compliance standard.

  • Platform auth: disabled (AllowAnonymous) so the app keeps the approved psi-portal auth profile instead of becoming an EasyAuth-enforced exception.
  • SPA auth: Entra ID via MSAL in the React client using redirect-based auth only.
  • Auth cache: MSAL localStorage so the portal behaves more like PSI’s pass-through internal apps and does not force frequent re-auth on user-specific workstations.
  • Client bootstrap: redirect result handled first, active account restored from cache/events, stale cached auth cleared before the next redirect.
  • Calibration API auth: server-side token validation in the Node/Express layer using the authenticated app-layer bearer token.
  • Route expectation:
    • / returns 200
    • calibration APIs return 401 when called without the required authenticated app-layer token

Quality Modules — Visibility

As of 2026-05-20, the labels sub-apps — Calibration Labels (/quality/calibration-labels), ZPL Lab (/quality/zpl-lab, reached via Calibration Labels), and Label Designer (/quality/labels) — are visible to all authenticated portal users. They were previously alpha-gated to a single UPN.

Calibration Portal (/quality/calibration) stays alpha-gated: U:\CALIBRATION\Calibration Log.xlsx remains the authoritative system of record for calibration data, and the portal’s CalibrationPortal Azure SQL DB is a migration target — not yet the live calibration system. Ungating the portal would invite confusion about where the canonical records live. It is hidden from non-allowlisted users until a cutover decision lands.

Two caveats still apply to the visible labels sub-apps:

  • Calibration Labels, ZPL Lab, and Label Designer all print to the real shop-floor Zebra ZD621 (10.150.122.37:9100). Test prints consume real label stock.
  • They share the CalibrationPortal Azure SQL DB for label templates and images, even though the calibration record UI is not yet exposed.

CapEx Portal (/finance/capex) also remains alpha-gated and is not in business use; its DB is on Basic and retirement is pending.

Calibration Module

The portal now includes a SQL-backed calibration module for operations and label printing.

  • Runtime data source: Azure SQL database CalibrationPortal
  • Database access: App Service managed identity
  • Legacy workbook: migration/bootstrap only, not used at runtime
  • Protected features:
    • calibration dashboard/search/detail
    • intake and update flows
    • label printing to the Zebra calibration printer

Label Preview — Labelary ZPL Rendering

The calibration label page shows a live WYSIWYG preview rendered by the Labelary ZPL Web Service, a free third-party API that converts ZPL commands into PNG images.

SettingValue
API endpointhttps://api.labelary.com/v1/printers/12dpmm/labels/2x1.25/0/
DPI parameter12dpmm (≈ 304 DPI — closest Labelary supports to our 300 DPI ZD621)
Label size2x1.25 (width × height in inches)
Request methodPOST with raw ZPL as the body, Accept: image/png
Timeout8 seconds (AbortSignal.timeout)

How it works:

  1. Browser sends form data to POST /api/calibration-labels/preview (Express server)
  2. Server builds ZPL from the form fields using buildCalibrationLabelZpl()
  3. Server POSTs the raw ZPL string to Labelary and returns the PNG to the browser
  4. Browser displays the PNG as the live preview

Important: The ZPL body must be sent as a raw string — not wrapped in URLSearchParams or form-encoded data= parameters. Labelary returns 404 for form-encoded payloads.

Preview is non-critical. If Labelary is unreachable or returns an error, the preview simply doesn’t render. The print path (direct TCP to the Zebra) is completely independent and still works.

Label ZPL Layout

The label is 600 × 375 dots (^PW600 ^LL375) at 300 DPI = 2” × 1.25”. All text uses ^A0I (180° rotation) because the label feeds inverted through the printer. The preprinted color bands (PSI logo at top, footer at bottom) occupy roughly the outer 75 dots on each edge; variable text is centered in the ~225-dot white band between them.

RowFieldJustification
1S/NCenter
2Cal date / Exp dateLeft / Right
3Calibration IDCenter
4Calibrated ByCenter

Font size is 42pt for all rows. Darkness is set to ^MD15 for the variable text, reset to ^MD0 at the end to avoid affecting subsequent labels.

Label Designer Module

Status: Beta. Open to all authenticated portal users since 2026-05-20.

A general-purpose, browser-based label designer (“Bartender-lite”) at /quality/labels. Coexists with the existing CalibrationLabels form and ZplLab raw editor — eventually intended to subsume both.

Features

  • Shared template library — anyone can read or edit any template. Templates are stored in the existing CalibrationPortal Azure SQL DB (two new tables: label_templates, label_images).
  • WYSIWYG canvas — drag-to-move, click-to-select. Resize and font/content edits via the property panel.
  • Element types — Text, QR Code, Code 128 barcode, Line, Box, and uploaded Image (PNG re-encoded to ZPL ^GFA).
  • Variables — author placeholders like {serial} and fill them in at print time.
  • Live preview — Labelary-rendered PNG, debounced.
  • Three view modes — Design (canvas), ZPL (read-only generated source), Preview (PNG).
  • Raw mode — power-user escape hatch that swaps the canvas for a free-form ZPL textarea. One-way: switching back to design mode discards the raw ZPL.
  • Single printer / selectable size — prints to the shared Zebra ZD621 at 10.150.122.37:9100 (same printer the calibration label uses). Label size is picked per template from a fixed list (2×1.25, 4×6, 3×1, 1×2, 4×2, 4×4) at 300 DPI.

Routes

PathPurpose
/quality/labelsTemplate gallery (list, new, copy, archive)
/quality/labels/newEditor — new template
/quality/labels/:id/editEditor — existing template

API surface (/api/labels/*)

All endpoints (except /health) require an Entra ID bearer token validated against the portal’s app ID 7f929c7f-….

GET    /api/labels/health
GET    /api/labels/templates              list (?includeArchived=true to show archived)
GET    /api/labels/templates/:id
POST   /api/labels/templates              create  { name, widthInches, heightInches, mode, designJson|rawZpl }
PUT    /api/labels/templates/:id          update
DELETE /api/labels/templates/:id          soft-archive
POST   /api/labels/templates/:id/copy

POST   /api/labels/render/preview         { templateId | design | rawZpl, variables } → PNG via Labelary
POST   /api/labels/render/zpl             same body → text/plain ZPL
POST   /api/labels/render/print           same body → sends ZPL to the Zebra ZD621

GET    /api/labels/images
GET    /api/labels/images/:id/png
POST   /api/labels/images       (multer)  upload, sharp-encode to ^GFA, persist
DELETE /api/labels/images/:id

Configuration

Environment variables (defaults in .env.example):

VariableDefaultPurpose
LABELS_DB_AUTHmanaged-identitymssql auth mode
LABELS_DB_SERVERprocserv-proddata.database.windows.netAzure SQL host
LABELS_DB_DATABASECalibrationPortalreuses the calibration DB; labels and calibration share a tier
LABELS_AUTH_CLIENT_ID7f929c7f-…portal Entra app
LABELS_AUTH_TENANT_IDa83ae943-…PSI tenant
LABELS_API_ALLOW_LOCAL_BYPASSfalsedev-only auth bypass

Schema apply

The schema (server/labels/schema.sql) is applied automatically on server startup via ensureLabelsSchema() in server/labels/database.js. The DDL is wrapped in IF OBJECT_ID(...) IS NULL / IF NOT EXISTS so re-running on every boot is safe. Failure is logged and non-fatal; the next call retries implicitly.

To apply manually (e.g. against a fresh database from a workstation with SQL access):

npm run labels:db:apply-schema           # apply to LABELS_DB_DATABASE
npm run labels:db:apply-schema -- --dry-run  # print batch count and exit

What is intentionally not built (yet)

  • CSV-batch printing (variables + N labels).
  • Multi-printer registry (today there is exactly one Zebra).
  • Round-tripping raw ZPL back into design mode.
  • Image trimming/cropping inside the editor.

Customer Data Reconciliation Module

Status: Live at /finance/reconciliation, open to all authenticated portal users. Finance / Accounting (AR) / Sales-ops tool.

Productizes the reusable company_analysis.py engine (in the d365-ce-migration workspace) so non-developers can review and clean up AFTEC customer data ahead of the Business Central + Dynamics 365 CE migration. Full evaluation: d365-ce-migration/docs/Customer-Data-Reconciliation-App-Eval.md.

The review screen is account-first master/detail, mirroring the real AFTEC hierarchy. A left rail lists Accounts (4-digit, relationship/site level) with a search box plus an “Unlinked bill-tos” tab; selecting an account shows its linked 6-digit bill-to Customers on the right. The rail displays accounts the same way the legacy VB6 Account Manager does (per the vb6-specs acceptance oracle extracted from frmAccount.frm’s grid-fill): rows sorted Name → Location, grouped by Account Name — a light-yellow header row per unique name (ptiClrLTYEL) with that name’s locations listed indented beneath it, collapsed until the header is clicked; clicking a location loads that account. A sort selector orders the groups by most sites (default), name A–Z (the VB6 BY UC.NAME order), or most customers.

Live AFTEC search (2026-07-15): typing ≥2 characters in the rail search box queries ACCOUNT.1287 live via the UniData API’s GET /api/accounts/search (same VB6 list contract), because the cached analysis only contains accounts the engine’s keyword regex matched — e.g. AFTEC holds 31 Chromalloy accounts across 11 name groups where the snapshot showed ~5. With an empty search box the rail shows the company’s snapshot accounts (a LIKE query can’t express the engine’s company regex); a status line states which mode is active. Live account keys (1!0409) are normalized to the snapshot’s bare acctCode form so snapshot customers and decisions join correctly; live accounts with no snapshot bill-tos show an explanatory empty state (they can’t be voted on until an analysis refresh includes them). The browser acquires the UniData API access token silently through the portal’s MSAL instance (same pattern as the Timesheet Tester). Acceptance harness: scripts/verify-account-search.mjs (ground-truth pull + expected-result simulation + --live endpoint diff, per oracle §5). Each customer row leads with its billing address + AP contact, surfaces its ship-to locations (where machines go) as a third tier, and shows a real activity signal — ARHIST last-invoiced date + a dormant flag (no invoice in 3+ years), which replaces the unreliable STATUS field. Data-quality hints (orphan bill-tos, duplicate-name clusters such as GE’s “GENERAL ELECTRIC” billing duplicates, mislinked/“wrong-account” bill-tos, “do not use”, shares-a-ship-to) drive a suggested vote.

Reviewers cast an FMK verdict per bill-to — keep / merge / kill / defer, plus link to place an orphan bill-to onto the correct account — with comments and inline pickers (merge-survivor for merge, target-account for link, defaulting to a ship-to match). The UI is framed as “your input — a vote, not a change”: verdicts are saved and reversible and never touch AFTEC or BC directly. They export as a source↔survivor crosswalk (CSV/JSON, with a linkToAccount column) that drives the AFTEC/BC cleanup and the CE load.

Engine reuse (no new API, no new DNS)

The analysis is not re-implemented. company_analysis.py already pulls AFTEC via the UniData API’s anonymous /dev endpoints and queries the Business Central virtual tables (dyn365bc_customer_v2_0) for coverage. A --json flag serializes that output; an analysis-refresh job --uploads it into the portal, which caches it and overlays the voting layer. No change to PSI.UniData.API; new route on the already-deployed portal, so no new DNS.

Data model (Azure SQL CalibrationPortal, self-bootstrapping)

TablePurpose
recon_company_analysisCached company_analysis.py output, keyed by company
recon_decisionsThe FMK verdict per bill-to (keep/merge/kill/defer/link + merge-survivor + link-target account)
recon_commentsFree-text flags / discussion, company- or record-level
recon_activityAppend-only audit log of every ingest / decision / comment

Schema self-applies on boot via ensureReconciliationSchema() — same idempotent pattern as the Label Designer.

API surface (/api/reconciliation/*)

All endpoints except /health require an Entra ID bearer token (and the reviewers group when RECON_REQUIRE_GROUP is set). The ingest route also accepts the refresh-job shared secret.

GET    /api/reconciliation/health
GET    /api/reconciliation/companies                                    list (cached analyses + decided progress)
GET    /api/reconciliation/companies/:key                              analysis + decisions + comments
PUT    /api/reconciliation/companies/:key/analysis                     ingest (x-recon-ingest-secret, or reviewer token)
PUT    /api/reconciliation/companies/:key/customers/:custNo/decision   { disposition, mergeInto?, linkAccount?, reason? }
DELETE /api/reconciliation/companies/:key/customers/:custNo/decision   clear verdict
POST   /api/reconciliation/companies/:key/comments                     { custNo?, body }
GET    /api/reconciliation/companies/:key/activity
GET    /api/reconciliation/companies/:key/export?format=csv|json       source↔survivor crosswalk

Configuration

VariableProd valuePurpose
RECON_DB_AUTHmanaged-identitymssql auth mode (App Service MI)
RECON_DB_DATABASECalibrationPortalshares the portal DB tier
RECON_REQUIRE_GROUP(set to group OID)gates access to the reviewers group (enforced server-side)
RECON_INGEST_SECRET(set)shared secret the analysis-refresh job uses to PUT analysis
RECON_API_ALLOW_LOCAL_BYPASSfalsedev-only auth bypass

Analysis refresh

A scheduled/triggered job on runner infra keeps the cache current — it runs the engine and uploads the result:

RECON_INGEST_SECRET= python company_analysis.py "GE" \
  --regex "(^ge[ /-]|general electric|…)" --name "GE / General Electric" \
  --json --upload https://portal.progressivesurface.com

PC Store & Fleet Module

Status: Alpha, IT-gated (/it/pc-store, Entra group via PCSTORE_ALLOWED_GROUP_ID with UPN-list fallback). DB: PcStore on procserv-proddata (managed identity; schema applied out-of-band — the MI cannot DDL).

Hardware provisioning + internal workstation fleet system of record. Two design docs in the repo are authoritative: docs/pc-store-design.md (v1: catalog, bundles, stations, assignments, orders → PC.<dept> Purchase Requests, onboarding queue, replacement planning) and docs/fleet-truth-design.md (Fleet Truth: the observation/resolver architecture).

v1 (merged #76): catalog + bundles by position/department/station, store orders that become AFTEC PRs (PC.<dept> + GL from GLCatCodes), onboarding queue, annual replacement plan (policy per device class), fleet import from the Thrive/RMM workstation export, Intune enrichment via the App Service MI (Graph app-roles GroupMember.Read.All + DeviceManagementManagedDevices.Read.All, plus User.Read.All since 2026-08-10 for the directory connector). PR #79’s employee accessory store never merged — it is tracked as its own issue; #79’s group gating and Intune enrichment were superseded by the IT Ops access model and Fleet Truth respectively (issue #124).

Fleet Truth Phase 1 (LIVE 2026-07-27, #92–#96): the fleet is evidence-based rather than hand-maintained. Go-live state: 381 devices, all three connectors green (Intune, Meraki — 521 clients across 12 networks, resolver), 67k DFS logon observations backfilled (240 users, multi-year), resolver writing confidence-scored assignments. The Meraki key is reused from Key Vault psizebratrackingkv (the connector fetches it at runtime via managed identity — App Service’s platform KV-reference resolver can’t reach the firewalled vault from Linux); MERAKI_ORG_ID is pinned (the MSP key also serves another org). Duplicate shells from the pre-identity v1 import are merged by an idempotent schema batch that also bootstraps identity keys on every deploy.

  • Sources write append-only observations: Intune poller (re-enrollment-stable azureADDeviceId), Meraki Dashboard API (client MAC → switch port/AP → zone), a PC beacon (scripts/beacon/FleetBeacon.ps1, Intune Win32 or ServiceUpdate.ps1 channel; reports logons, monitor EDIDs, docks) and a one-shot DFS logon-log backfill (\\ad.ptihome.com\DFS\Data\_Updates\Logging\).
  • A resolver derives per-device assignment (dominant user over 60 days), user-assigned vs shared classification, zone, and liveness — with confidence and a human-readable basis; IT pins exceptions; contradictions land in a conflict queue (spare-with-telemetry, pinned-contradiction, unseen-45d, ambiguous-user).
  • Dual assignment model: devices are dedicated or pooled; pools are fixed-seat (CNC/Weld/Waterjet stations — seat = machine, device swaps behind it) or floating (stockroom cart laptops); users get a usage mode (dedicated/shared) from the canonical position mapping. Coverage is computable from both sides.
  • Spares & swap (PRGJSMES pattern generalized): lifecycle in_use|needs_prep|spare|broken|retired; one-transaction swap where the reason code drives the outgoing device’s status; paired custody events in an append-only device event log.
  • Background scheduler is inert unless FLEET_SYNC_ENABLED=true; every connector has a manual trigger and a health row surfaced on the dashboard.

Day-two additions (2026-07-28): a person directory (pcs_person_directory: UPN ↔ SAM ↔ department ↔ job title for all enabled Entra members, fed automatically by User Explorer’s load) gives the resolver identity folding — DFS VIP and Intune VPelton@… count as one voter — plus department inheritance (dedicated devices take their person’s department, pool members the pool’s) and a dedicated-biased usage-mode default. A Dell TechDirect warranty connector backfills purchase/warranty dates by service tag (credentials in Key Vault; connectors resolve @Microsoft.KeyVault(...) references themselves via the MI because App Service’s platform resolver can’t reach firewalled vaults from Linux). UX: one persistent PC Store shell (section tabs + breadcrumb + connector health on every page), a device side-panel drawer from any list (assignment, pin via Entra typeahead, pool assign/remove, last-10 timeline), human switch names on placements (Meraki org-device cache), a Dept column, bulk retire, and auto-retire of in_use devices unseen by every source for 180 days (FLEET_AUTO_RETIRE_DAYS; spares and never-seen rows exempt). Position/department rules pick from real Entra job titles/departments (/fleet/directory/facets) instead of typed codes.

Asset labels — the computer name IS the asset tag (2026-07-28): PSI has no separate IT asset-tag number and does not need one. Intune enforces the PS-<serial> naming standard, so the computer name already carries the immutable vendor-stamped serial and every source reports it (Intune, Meraki hostname, the beacon, the DFS logon logs). A separately allocated number would be an identifier no telemetry source knows — un-derivable, so it could never self-heal, which is exactly the assertion-rot the fleet is designed to avoid. An audit of the live fleet settled it before the design was fixed: of 447 active devices, 0 have no name, 0 have a duplicate name, and 186 have no serial recorded at all — the name is both unique and more complete than the serial. asset_tag therefore survives only as an override for a machine already wearing another system’s sticker (a finance or MSP tag); it is never allocated, a filtered unique index guards it, a set/clear writes an AssetTagged custody event, and clearing it returns the device to printing its name. The label reuses the Label Designer stack end to end — its ZPL builder’s native ^BQ QR, the Labelary preview, and the same Zebra ZD621 raw-TCP send on the 2 x 1.25in stock already loaded — so there is no second printer path. The QR encodes the portal device deep link, so scanning a sticker on the shop floor opens that machine’s assignment, evidence basis and timeline on a phone. Where a device has a serial but a name that does not contain it, the label prints PS-<serial> as a pseudo-name — the name the machine would have under the standard — with the current hostname on the line beneath, so the sticker survives the eventual rename while still matching what Intune shows. This is what covers the ~55 Intune-enrolled machines still on legacy names, most of them Zebra scanners named by AOSP enrollment (B1NCR_AndroidAOSP_6/30/2025_1:28 PM) which will never follow a PC convention. The identifier’s font scales across four sizes for long serials, and the hostname line is suppressed when it would merely repeat the identifier. Surfaces: an asset-label panel on the device drawer and full page (what will print, preview, print, and a tucked-away override) plus bulk Print asset labels from the grid selection — there is no “assign” step because there is nothing to assign, and no asset-tag grid column because the Device column already is the tag. Routes: POST /fleet/asset-tags/{zpl,print}, PUT /fleet/devices/:id/asset-tag, GET /fleet/devices/:id/asset-tag/preview.

Pool assignment and curated vocabularies (2026-07-28): pool membership is edited where the device is — a shared control on both the device drawer and the full device page, plus bulk Add to pool from the fleet grid selection (POST /fleet/pools/:id/devices/bulk, floating pools only: a fixed seat holds exactly one device, so a batch has no seat to land on). Bundle and pool targeting picks from real vocabularies rather than typed codes: job titles come from Entra’s enabled-user roster with headcounts (/fleet/directory/facets), and a department is always a code from the GLCatCodes master list served by the UniData API — the same list the Purchase Request editor and replacement plan use, because a pool’s department is the cost center its PC.<dept> PRs are charged to. position remains the stored match type; the UI only ever says “job title”.

Schema drift is fatal, not a warning (2026-07-28): the deploy’s Apply PcStore schema step used to end in || echo "::warning::…". When the runner MI’s PcStore DB grant turned out never to have been created, every deploy from 2026-07-27 warned and shipped anyway while the database fell behind the code — until a missing pcs_network_device 500’d every device click in production. The step is now fatal, and npm run pcstore:db:verify-schema reads the CREATE TABLE list out of schema.sql and exits non-zero on anything missing, so drift is an exit code rather than whoever clicks first. The runner MI (ps-cicd-runner) needs db_datareader + db_datawriter + db_ddladmin on PcStore (npm run pcstore:db:grant-user); the app’s own MI still has no DDL rights by design.

Schema gate runs on the PR too (2026-08-11): after three deploys failed on schema problems PR CI called green (bad aggregate DDL, a comment-parsing phantom table), the CI verify job now spins up an ephemeral mssql:2022 Docker container, creates the four databases, and runs the exact apply-all-schemas + verify-all-schemas the deploy runs. Both self-hosted runners have Docker (installed on ps-cicd-runner-2 2026-08-11 — remember runner fixes must be applied to BOTH VMs). The verify script also strips SQL comments before extracting table names, so prose like “the CREATE TABLE statements above” can’t mint a phantom table again.

The fleet is end-user devices only (2026-07-28): twenty VMware/Hyper-V guests running Server OS (AS1, SQL1/2, DC1, PS-PROXY, PS-GR-FS01, PS-MACRIUM, PS-AZ-DC01, …) had drifted into the PC fleet, inflating the device count and everything derived from it. The root cause was a data gap rather than a missing filter: the observation writer created device shells with device_class = 'unknown' and discarded the payload’s hardware facts, so model / manufacturer / os_version sat NULL on every Intune-created row while the answers were in pcs_fleet_observation.payload one table over. server/pcstore/deviceKind.js now classifies from that evidence — hypervisor model/manufacturer strings plus the Windows Server kernel build (14393 / 17763 / 20348 / 25398) — so it is derived telemetry, never a name list, and keeps working when the next server is built. It deliberately refuses to guess on build 26100 (Server 2025 and Windows 11 24H2 share it) and requires the hypervisor signal to break the tie on 17763 (also Windows 10 1809), so a virtual desktop remains an end-user device. Facts are promoted onto the row on the way in (COALESCE only; a class is promoted only from unknown, since a human or the RMM import saying “laptop” is a decision a connector does not overrule), and servers are excluded from listFleet (with an includeServers escape hatch), fleetSummary, replacement-plan generation and the resolver’s device load. Backfill: npm run pcstore:fleet:classify-servers (dry-run by default, --apply to write).

Network clients are not fleet devices, and the total reconciles with Intune (2026-07-28). The Meraki connector used to auto-create a fleet device for every client it saw on a VLAN, which enrolled ~110 non-computers into the PC fleet: personal phones and watches, Ricoh/Lexmark/Zebra printers, Raspberry Pis, GE appliances, Resideo alarm panels, a Yealink desk phone, a QNAP NAS, a Kaeser air compressor, and the ARGO/PRG/BLO inspection machines (customer equipment, not PSI-managed). A bare Meraki sighting is now born network-client — out of every count, but kept, so it lands in a review queue where a genuine non-Intune PC can be found and promoted (POST /fleet/devices/:id/promote). Any corroboration — a non-Meraki source, or an observation reporting a user — lifts it back automatically, since an air compressor never logs anybody on. Three supporting fixes landed with it: cross-kind identity matching (Meraki reports the Zebra scanners’ hostname as their serial string, which a strictly per-kind match turned into ten duplicate shells; MACs are excluded from cross-kind matching because a NIC moved between chassis is not one device, and a value already on two devices is left for the conflict queue); chassisType in the Intune $select, which is the only thing that settles laptop-vs-desktop since Dell ships Precision as both a tower and a mobile workstation (this one misfired — see The Intune connector was down for five days below); and management ids promoted onto the device row — a machine first created by the RMM import and later matched by serial kept a NULL intune_device_id, so “is this managed?” read false for machines Intune manages. The fleet grid now has a scope switch (Fleet ⇄ Review queue), an Intune column, and a “Not in Intune” filter. Result: an end-user fleet of 276 = 252 Intune-managed + 24 not, against Intune’s own count of 251; the 24 unmanaged are almost all DFS-log-only domain PCs that were never enrolled, which is the actionable list.

The Intune connector was down for five days (2026-07-30 → 08-04). chassisType is a beta-only property of microsoft.graph.managedDevice; the connector asks v1.0, so every sync 400’d with “Could not find a property named ‘chassisType’” from the hour that change merged. What turned a bad field into a five-day outage was the retry: the existing degrade-on-400 path’s “safe subset” also listed chassisType, so the retry failed identically. The connector now reads the property Graph names in its own error and drops exactly that field (offendingProperty()), asking beta only while it still wants chassisType and falling to v1.0 the moment it does not. Core fields are not droppable (a row we cannot key is worse than a visible failure), a 400 naming nothing we asked for throws, and a degraded run reports what it lost. Second bug the first was hiding: classifyChassis only knew SMBIOS names, so five of Intune’s nine chassisType members returned null — including worksWorkstation, about a quarter of PSI’s engineering PCs. Nobody noticed for five days because a red connector chip is not an alert: connector alerting to Teams is tracked in psi-portal#126, and each chip now has a Sync now button (the manual endpoints existed from the start and nothing called them, so recovery previously meant a redeploy or a six-hour wait).

People, not AFTEC ids (2026-07-30). The fleet showed bare three-letter ids (CAM, NXT, ALP) wherever a person belonged — the grid’s Assigned to, the drawer, the timeline, and inside conflict sentences. The translation table was already there (pcs_person_directory); only the write path used it. Resolution moved onto the read path via OUTER APPLY … TOP 1 ORDER BY account_enabled DESC — an APPLY rather than a JOIN because AFTEC recycles 3-letter ids and a join would silently duplicate fleet rows. An id the directory cannot place is still shown, in warning colour, because a directory gap is worth seeing; it must simply never read as somebody’s name.

Equipment for a hire or an open position (2026-08-04). The onboarding board could always model an open req (pcs_hire.kind = 'position'), but every downstream row was keyed on a login a position does not have — so a position could be given a bundle and nothing could be ordered against it. A position now plans against itself (subject_type = 'position', subject_ref = POS-<hire_id>, stable across a retitle). Ordering then moved off the annual replacement plan: a new hire is not a replacement, and filing new equipment among hundreds of lifecycle rows made it unfindable. Journey → Order equipment writes a provisioning store order, raises the PR, links the number back and stamps equipment_ordered_at, so the milestone comes from linkage rather than from memory.

PC → department ownership, end to end (2026-08-12/13). PCs are bought against one department’s budget and must not drift untracked. pcs_fleet_device.owner_dept_code is the durable owner (set once; later changes are confirmed transfers with timeline events; the grid’s Owner dept column flags owner-vs-usage discrepancies). pcs_order_unit is the join PR → PO → service tag → department → fleet row, fed four ways: the Dell Premier orders export and invoice PDFs (which price each tag) via the Orders page’s Import Dell docs; Autopilot purchaseOrderIdentifier on the Intune sync (also the PSI-owned vs resale-machine-PC discriminator — resale units are never given fleet rows); and the AFTEC sweep (Pull PC PRs from AFTEC): the UniData API’s /purchase-request/pc-hardware lists every PR with a PC.<dept> line, each is registered as a historical store order with the dept straight off the part code, POs persisted, and units re-attributed (COALESCE-only). Receiving is a dock verb (POST /fleet/order-units/receive): the fleet row is created before Intune ever sees the machine — needs_prep for hire/replacement units, spare for department stock. The TechDirect/Premier Order Status API connector is built and negotiation-hardened but awaits Dell Integration Team credentials (psi-portal#230); its schedule is paused until then.

Filling and repairing a position (2026-08-11). Two rules from the first real hire through the board. The person’s job title is the official one: a fill from the case inbox keeps the HR request’s title and department (the position’s wording may be an early placeholder), and Move to another position keeps the hire’s own title — the position supplies department and manager as org placement, and its title only fills a blank. And absence is never silent: the Move modal lists every position — eligible ones are clickable, a closed one is greyed with the reason and offers Reopen & move here (POST /hires/:id/move with reopen: true restores one seat with an audit event before the normal consume); only a cancelled req is a dead end.

Which PR equips whom (2026-08-07). AFTEC’s PR record has no “for whom” field — only free-text header notes — so pcs_store_order is the only place that join exists, and nothing displayed it. The order register (/it/pc-store/orders) lists PR number · who it equips · department · why (provisioning / employee store / replacement plan) · status · who raised it · when, and the journey’s equipment step names the PR. Both directions matter: purchasing arrives holding only P01461, or IT asks what was ordered for a new hire.

Supplier comes from the catalog, not from “Dell” (2026-08-04). All three PR paths hardcoded or defaulted the header supplier to Dell, so a Yealink WH62 (AUD-002, a CDW line) went out addressed to Dell — and c.supplier was never even selected in the bundle-items query, so the client could not have known. lib/pcStore/prLines.ts now owns line construction and the rule for all three paths: one vendor on the header when every line agrees, header left blank with an explanatory note when they do not (partly-known counts as disagreement). Purchasing has since confirmed a mixed-vendor PR is fine as one document (psi-portal#141, closed 2026-08-07), so a split is deliberately not implemented; the vendor link on each line is what a buyer works from, and the per-line supplier notes that were briefly added were removed as noise. Production is the default target everywhere a PR can be raised; a PR quietly created in sandbox looks exactly like one that was never created.

Pages declare how much of the monitor they use (2026-08-04/07). Layout caps content at 1152px unless a page says otherwise, and most did not: the PR approval screen asked for max-w-7xl inside that cap and rendered ten editable columns in ~760px. Pages now declare wide or full (usePageWidth), a saved PR reads rather than presenting the line editor to an approver, and tests/client/pageSpec.test.ts enforces both rules over every routed page — including a ratchet on hand-rolled <table>s (psi-portal#136) that can only shrink. The rules are written down in docs/ui-spec.md.

A silent connector now speaks up (2026-08-10). The Intune connector failed for five days and nobody knew: the fleet strip showed a red dot on a page no one had open, and every run swallowed its own error. It alerts through the psi-notify alert engine — PSI’s standard, not a private webhook — with a deliberately quiet policy: one message when a connector starts failing, at most one a day while it stays broken, one when it recovers, and a separate alert when a connector simply stops running (no successful run in three of its own intervals), which is the failure mode that produced the five-day gap since a connector that never runs never errors. A degraded-but-successful run is a note on the strip, not an alert. A watchdog sweeps on a timer so staleness is caught without waiting for a run that may never come, and the decision logic is pure and unit-tested (server/pcstore/connectorAlerts.js) so the cadence is provable rather than observed in production. sendAlert treats {matched:false} as not delivered — a 200 from the engine with no matching rule reaches nobody, which is exactly the silence being fixed. Routing rule: PSI Portal fleet connectors (Teams-only, Warning), created with npm run fleet:alerts:register-rule.

Key env vars: PCSTORE_INGEST_SECRET (beacon shared secret), MERAKI_API_KEY/MERAKI_ORG_ID, FLEET_SYNC_ENABLED, FLEET_*_MINUTES, FLEET_AUTO_RETIRE_DAYS, FLEET_WATCHDOG_MINUTES (staleness sweep; 0 disables), DELL_API_CLIENT_ID/DELL_API_CLIENT_SECRET, PSI_NOTIFY_ALERT_URL + PSI_NOTIFY_ALERT_KEY (the alert engine’s /api/alerts/inbound and a function-scoped key, stored as psi-notify--alerts-inbound-key-portal in ps-certificates-kv and referenced with @Microsoft.KeyVault(...)), PORTAL_PUBLIC_URL (the base the asset-label QR points at).

Note on that Key Vault reference: ps-certificates-kv uses access policies, not RBAC, and the portal’s managed identity had no policy there at all — it needed get on secrets. Both Dell and Meraki secrets are resolved in-process by the app rather than by the App Service platform resolver, because the platform resolver cannot reach a firewalled vault from Linux; server/lib/keyVaultRef.js is the single implementation and supports both reference spellings (SecretUri= and VaultName=;SecretName=).

IT Ops — the merging surface (2026-08-10)

Done (issue #131, closed 2026-08-11): PC Store & Fleet, User Explorer, and the Onboarding board merged into one IT Ops surface. Design: docs/it-ops-one-surface-design.md in the repo (the umbrella doc — doc map, two-tracker decision, external-system contract). The migration ran additively through a one-day burn-in, then decommissioned on 2026-08-11 after an agent-run feature audit (route/verb parity matrix; verdict READY-WITH-EDITS, edits shipped with the decom): the legacy routes (/users*, /onboarding*, /it/pc-store/*) are redirects, the legacy nav rows and launcher tiles are gone — one tile, one surface.

Live at /it:

PieceWhat it is
One access modelGET /api/itops/access + ItOpsGuard — an Entra group (ITOPS_ALLOWED_GROUP_ID, transitive checkMemberGroups via the MI, 5-min cache) with UPN bootstrap lists unioned from the legacy PCSTORE_ALLOWED_UPNS/PROVISIONING_ALLOWED_UPNS. Capabilities (policyEdit) ride the same probe. Fail-closed on Graph outage. ITOPS_ALLOWED_GROUP_ID is not set in production as of 2026-08-18, so access is currently the UPN lists alone — psi-portal#255. Check the setting, never this table.
Who can be assignedGET /api/itops/people — the members of that same group, transitively, plus the bootstrap UPNs, enabled accounts only. Every assignment field picks from it (see People pickers below). It reads the same source as the gate on purpose: a picker that disagrees either offers a dead end or hides a real colleague. source says whether the answer came from the group, the bootstrap lists, or an unreadable Graph.
/it/* IAOverview / Onboarding / People / Fleet / Hardware / Admin — the same page components as the legacy routes, wearing a shared section nav (isItOpsSurface() decides the chrome).
Journey × pipelineThe Journey right rail shows the bot case (caseRef PSI-nnnn = dbo.Cases.Id): stage, the approved plan snapshot (ProvisioningPlanJson), post-provision tasks with capability-gated retry/cancel, waiting_license/failed-task blockers, case-event diary. Nothing is copied onto pcs_hire — the case stays authoritative.
entra-directory connectorpcs_person_directory is a scheduled Graph roster sync (12 h + Sync now) with health/watchdog/alerting, instead of a side effect of opening User Explorer. Truncated-roster guard fails loud.
Person page/it/people/:key (SAM, UPN, or local part) — identity + recon warning, licenses/badge via the same single-user Graph read the Journey uses, evidence-based device assignments, and the “which PR equipped them” order register. The People drawer links to it.
At-risk digestHourly job: hires starting within 7 days with missing stored milestones or a troubled bot case reach the IT Cases chat via the psi-notify alert engine (rule PSI Portal onboarding, Teams-only/Warning). Quiet policy is pure + tested: send on appear/change, one reminder a day, one all-clear. Sent-state in dbo.pcs_alert_state.

Sharing the onboarding calendar beyond the portal is designed as a tokenized ICS subscription feed (not yet built) — never Graph-written events, which would create a second stateful writer; orientation invites to the assigned tech stay bot-side.

Shipped overnight 2026-08-10→11, on the same surface:

PieceWhat it is
Deployment plans (/it/hardware/deployments)A batch of earmarked device→device replacements run as a mini-project (the “BOXX rollout” case): seeded from a fleet-grid selection or a PR on the order register (candidates matched from the PR’s lines), person-aware earmarking, per-item planned dates/status, and Swap executing the existing one-transaction swap machinery. Two filtered unique indexes hold the invariants (one live earmark per outgoing PC; one live plan per incoming machine). Tables pcs_deployment/pcs_deployment_item.
Mobile tech flowA phone opening /it/fleet/:id (the asset label’s QR) gets a thumb-sized device card — identity first, then pin-to-person, pool membership, laptop/desktop classify (PUT /fleet/devices/:id/class, new), photo capture, and print-label (server-side Zebra send). Fleet → Scan (/it/fleet/scan) searches name/serial/service tag with camera barcode support. Device photos live in pcs_device_photo (sharp-normalized to ≤1600px JPEG, authenticated reads, 20/device cap, custody events).

Procisely Module

The portal includes the admin interface for Procisely.com, PSI’s URL shortener and asset link management platform. The Procisely admin is accessible at /procisely within the portal and via the Procisely tile on the home page.

Features

  • Dashboard — summary stats, quick-create from template, recent activity
  • Redirect Management — CRUD with filtering, bulk operations, CSV export
  • Pattern Rules — regex-based runtime routing with live tester
  • Templates — creation-time helpers for standardized redirect patterns. Placeholder tokens {serial}, {value}, {asset}, and {tag} are interchangeable, so CMMS-style destinations read naturally (e.g. https://your-cmms/wo/new?assetTag={asset})
  • QR Code Generation — single and batch, with optional PSI logo overlay, print-ready output
  • Bulk Import — CSV upload or template-based list import. Paste/upload a list of values (e.g. equipment asset tags) and one redirect is created per value
  • Mass QR Labels (print-ready PDF) — from the pasted list (Bulk Import → Template Import) or any selected redirects (QR Generator), generate a single PDF of QR labels — each label is a QR (encoding the stable short link) plus the human-readable tag and an optional caption — at a chosen physical size (1×1 … 4×2 in or custom), laid out N-up on Letter/A4 or one-per-page for label stock. Common use: a QR on each piece of equipment that opens its CMMS maintenance-request page
  • Analytics — click trends, top redirects, category breakdown

Database

SettingValue
DatabaseProcisely (on procserv-proddata)
AccessPortal managed identity with procisely_admin role
Env varsPROCISELY_DB_SERVER, PROCISELY_DB_NAME, PROCISELY_DB_AUTH

API Routes

All Procisely API endpoints are namespaced under /api/procisely/* with Entra ID bearer token authentication. Health check at /api/procisely/health does not require auth. Notable endpoints include POST /redirects/bulk-onboard (template + value list → mass-create, returns the created/existing items) and POST /redirects/labels-pdf (print-ready QR-label PDF).

The QR Generator also has a “From a pattern rule” panel: pattern rules deliberately have no redirect records, so the assets they cover never appeared in the record picker. Pick the rule, paste a column of values, and each derived slug is validated against the rule’s real regex before it can be printed. Print stickers then builds a PDF at four per Letter sheet, with machine names pulled from TAG. See procisely.

This gives the portal a read-only TAG client (server/procisely/tagClient.js, GET /api/procisely/assets) — listEquipment only, confirmExecution never sent, cached 5 minutes. Needs MOBI_API_KEY (Key Vault reference) and MOBI_CONNECTOR_INSTANCE_ID; without them the panel degrades to printing codes instead of names.

Two Procisely bugs worth knowing about, both fixed August 2026:

  • dbo.PatternRules has UNIQUE (Priority) while createPatternRule hardcoded priority ?? 100 and the UI has no priority field — so only ever one pattern rule could be created through the admin; every one after it failed with an opaque 500. Priority now claims the next free slot. isActive was also accepted and then silently dropped, making the Active checkbox a no-op on create.
  • The pattern-rule form recommended named groups ((?P<serial>...)) and {serial}, and its live tester substituted {1}none of which the redirect engine supports. It would validate a template that produces a broken redirect. Both now mirror variables.ts, and the tester reports any placeholder that won’t be substituted.

See Procisely wiki page for full API documentation, architecture, and security model.

Timesheet Tester Module

Route /tools/timesheet (alpha-allowlist gated). A web front-end for the AFTEC weekly timesheet, built to exercise and eventually replace the WPF timesheet app. It calls the UniData API timesheet endpoints through the portal’s own MSAL instance — no second sign-in — acquiring a token for the UniData API scope (not Graph) via the base useAuth().getAccessToken() hook.

  • Target toggle — Sandbox (default, /api/timesheets/dev → PS-MRPSANDBOX) or Production. Both run as the signed-in user in UniData; only the server differs.
  • Find Timesheet — one search by employee + week. “Load week” queries both sources for that week and merges by date: days still in the working file show as Unposted (per-day TS.HEAD.1287 reads), posted days show as Posted (labor history via VB_TIMESHEETINQ.REV3). The week field defaults to the current week with ◀/▶ week jumps.
  • Find my timesheet — resolves the signed-in user’s AFTEC employee number from the Entra employeeId (Graph /me) and loads the current week.
  • Search by name/employees?q= (min 2 chars, partial/any-word-order/punctuation-tolerant) → pick an employee to load their week.
  • Edit a day — unposted days in the results carry an Edit button that loads that day (emp, date, dept, header buckets, labor lines, and any attached machine time) back into the Save grid; posted days stay read-only (immutable labor history).
  • Save — build week entries with dropdowns fed by /refdata: Dept/Cost Ctr, Work Center, Operation (cost-center→work-center→operation cascade via CWO.XREF.1287), and Misc Type for misc time. Free-text inputs remain as a graceful fallback if refdata can’t load. Entries save one TS.HEAD.1287 record per day (grouped by day-of-week); only regular entries become TS.1287 labor lines, while sick/vacation/holiday/misc/off-duty are header-only hours (WPF card model).
  • Machine time — a regular labor entry can attach a MACH.TIME.1287 detail (part, qty, setup/labor hrs, adj setup/labor, indirect, shift). New records get an id from the shared DICT MACH.TIME.1287 'CNT' counter server-side (the same counter the WPF app increments); Adj Setup/Labor overwrite the line’s hours.

User Provisioning Module (2026-08)

The portal is the home of the user-provisioning policy — the answer to “what does a new hire in department 125 with this title actually get?” The IT Help Desk bot asks; the portal answers; the bot never decides. Policy used to live in a JSON file in the bot’s repo, which meant every licensing change was a pull request. It is now editable data owned by IT.

Route /admin/provisioning (Provisioning-Editors group). Database: alerts on procserv-proddata — the same database the bot already reads, so no new connection, DNS or private endpoint.

Data model (dbo.prov_*)

ObjectHolds
prov_departmentthe 18 canonical departments, their names, licence, and auth_model
prov_title_rulea named rule (licence + mailbox + group sets) a title or department points at
prov_title_rule_titlethe job titles each rule covers — a rule can cover several spellings
prov_title_refthe 79 job titles seen in the directory, each mapped to a rule
prov_rule_grouprule → group membership, by bucket: onprem, cloud_assigned, cloud_dynamic
prov_change_logevery policy edit: actor, before/after JSON, timestamp
prov_post_tasksthe on-prem work queue (see below)
prov_password_handoverthe initial-password audit trail (see below)

The approved plan itself lives on the case (dbo.Cases.ProvisioningPlanJson), not in a prov_* table — the case is authoritative for its own provisioning, so nothing is copied.

Reads happen through views, never the tables:

  • vw_prov_matrix_json — the whole matrix as one JSON document, shaped exactly like the file the bot used to ship. That is what makes the swap invisible to the listener.
  • vw_prov_matrix_effective — a rule flattened per department/title, for the UI.
  • vw_prov_matrix_check — the subset invariant: a title rule may add to its department’s groups but never silently drop one. cloud_dynamic groups are excluded from the check, since membership there is computed by Entra rather than granted.

JSON_QUERY must be the outermost expression or a group array embeds as an escaped string rather than JSON. Caught by running the view against the real database, not by tests: the bot would have iterated the characters of ["A","B"] and tried to create groups named [, " and A. Any new FOR JSON PATH view here needs the same care.

Revision and sign-off

Every rule carries a revision, and confirmed_revision records what IT last signed off on. The derived needsConfirm (revision <> ISNULL(confirmed_revision,-1)) is what the bot’s dispatcher reads: an unconfirmed rule blocks automatic provisioning and the case falls back to a human, which is the correct failure direction for something that hands out licences and door access.

Override bits (ovr_licenses, ovr_groups, …) exist so a rule can say “this title genuinely gets no on-prem groups” distinctly from “nobody has said.” Silence is never read as an empty list.

On-prem post-task worker (onprem/post-task-worker/)

Entra’s inbound provisioning writes the account; what it cannot do is set manager on the on-prem object, add on-prem group memberships, seed extensionAttribute2, or set a password. So the bot (and, for the password, the portal) writes prov_post_tasks rows — set_manager seq 10, add_ad_group seq 20+, set_attribute seq 15, set_password seq 30 — and a Windows service on PS-PROXY drains them against AD.

  • Claims work with READPAST, UPDLOCK, ROWLOCK so two instances cannot take the same row, and holds a lease so a crashed run’s rows return to the queue rather than sticking as “in progress.”
  • Runs as a dedicated account with narrowly delegated rights — write manager, write extensionAttribute2/15, member on the specific groups, and (since 2026-08-11) Reset Password scoped to descendant user objects only, via extended right 00299570-246d-11d0-a768-00aa006e0529 — granted by Grant-WorkerAdRights.ps1 and verified against each attribute’s presence in allowedAttributes (the authoritative list; hand-walking mayContain gives wrong answers on constructed attributes).
  • Installed by Install-PostTaskWorker.ps1 (WinSW + PostTaskWorker.xml); SQL access by Grant-WorkerSqlAccess.sql. Tests: tests/PostTaskWorker.Tests.ps1.

Status (2026-08-11): live and proven. Case 1029 (Kurt Leatherman, dept 125) went through it on 2026-08-10 at 17:55 — set_manager plus eight add_ad_group tasks including the three door groups, every one done on the first attempt in seven seconds. PROVISIONING_POST_TASKS_ENABLED is on. That retires the manual on-prem add for hires the bot provisions, and clears the last precondition for removing the Entra manager mapping (psi-notify-bot#42, build-order step 14).

The set_password half is not yet deployed — the worker’s plan/execute code is merged but the service on PS-PROXY still runs the pre-set_password build, so the portal endpoints can queue a task that nothing will drain. Two host changes finish it: add "keyVaultName": "ps-certificates-kv" to C:\Services\PSI.PostTaskWorker\worker.settings.json, then run the installer. Both Key Vault grants already exist (the worker’s SP has secrets: get, the portal’s managed identity has set/delete/getps-certificates-kv uses access policies, not RBAC).

Run the installer from a staging copy, never from the install root. On PS-PROXY that is C:\temp\post-task-worker, refreshed from the repo first. The install root holds reference copies of the installer and grant scripts so the deployed service is self-describing, and the installer refuses to run from there rather than overwriting every file with itself. installed-from.json records the install time, who ran it, the source path, the mode, the worker’s SHA-256 and the task types that build handles — because “does the running worker know about set_password?” was not answerable from the host, and a service can sit there Running while silently predating a task type.

The installed worker.settings.json wins over the staged one. Settings are host state; the staged copy goes stale the moment someone edits the box. Discovered the hard way while staging this build: the staged copy said "mode": "NoOp" while the service was running Live, and the old source-wins precedence would have demoted a live worker to logging “WOULD DO” — Running, and doing nothing. Drift is now reported key by key and the host copy is used (-OverwriteSettings forces the other way).

Four prerequisites were missing on the host and none were visible from the repo — RSAT-AD-PowerShell, the SqlServer module, the worker’s SQL grant (without it the service reports “Running” and fails every poll), and “Log on as a service” for the account (event 7041, which reads like a password failure and is not one). The installer’s read-only preflight now names each.

Azure SQL auth is a certificate service principal, not integrated auth. Integrated cannot work on PS-PROXY: it authenticates as NT Authority\Anonymous Logon (no Kerberos→Entra federation) and Entra then demands MFA of the service account (AADSTS50079); managed identity is unavailable because the host is not Arc-enabled. So app registration PSI Post-Task Worker holds a certificate generated on the host (NonExportable, service account has Read on the key), and because Microsoft.Data.SqlClient accepts only a client secret in its Authentication=Active Directory Service Principal mode, the worker signs an RS256 client assertion and sets SqlConnection.AccessToken. Nothing secret is stored or transported. New-WorkerSqlCertificate.ps1 scripts the certificate and its ACL for the 2028 renewal.

Initial-password handover (2026-08-11)

The last thing a hire needed that the pipeline could not give them: a first password. The bot created the account, set the manager, added the groups and licensed it — then a tech set a password by hand, which in practice means a sticky note, a Teams message, or one shared Welcome123 that half the plant knows.

It now works end to end. On the hire’s Journey page, under the provisioning panel:

GET /api/provisioning/hires/:caseId/initial-passwordstate + the whole audit log
POST …/initial-passwordgenerate, park it in Key Vault, queue the on-prem set
POST …/initial-password/revealread it again — logged, every time
DELETE …/initial-passwordretire the stored value once the hire has changed it

The password is generated in the portal, stored in Key Vault under onboard-pwd-<caseId>, and set on the AD object by the PS-PROXY worker (set_password, sequence 30). The value never travels through the queueprov_post_tasks.task_arg carries the secret name, and the worker fetches it itself. That matters because last_error from that table is rendered in this very UI.

Two audiences, two levels of access, and that split is the design.

  • The log is visible to everyone with provisioning access: “Generated by CHosakura 09:12 · read 2× (CHosakura 09:14, BSummers 13:40)” — a read by a second tech renders in the warning tint. Making the audit trail visible to the wider IT Ops team is the control; hiding it from everyone but the people who can press the button would defeat the point of keeping it.
  • The value is limited to the HelpDesk Admin Group, checked server-side and failing closed. That gate grants no new power — every member of that group can already reset any password in the staff OU directly. What it does is make the ordinary case cheap enough that nobody invents a worse one.

Re-reading is allowed and logged each time. Refusing a second look does not remove the risk, it relocates it into a screenshot nobody can audit.

Ordering that is not arbitrary:

  • Key Vault write before the enqueue. Reversed, the worker could claim a task whose secret does not exist yet and fail it for nothing. This way a failed enqueue leaves an orphaned secret that expires on its own.
  • Generating again supersedes. Any pending/failed set_password row for the case is cancelled first — two pending rows would both run, the loser’s secret landing second, leaving the tech holding a password that no longer works.
  • The reveal is logged before the value is returned. A read that happened and was not recorded is worse than one recorded whose response never arrived.
  • Expiry is start date + 48 h (24 h floor), so a password generated a fortnight early is still there on day one and one nobody collected does not linger.

Badge-only vs badge+password

auth_model on a department or title rule answers one question: is IT ever handed this person’s password? Badge-only staff still get one — long, random, set once by the worker and never revealed — because an AD account without a password is not a thing. The hire page says “Not required” rather than offering a reveal for a credential nobody will type.

As of 2026-08-11 the badge-only population is five Process Services title rules: Machine Operator, Machine Lead Operator, Thermal Spray Operator, Waterjet Operator, Process Technician (scripts/mark-badge-only-rules.js). Not Material Handler (F3, 2 GB mailbox, signs in), not Quality Inspector/Specialist (Business Premium for the Access-based quality apps), not the supervisors. All 18 departments stay badge+password, because each currently reports every one of its people holding Business Premium — the split is by title inside 125, which is what a title rule is for.

This is deliberately decoupled from the licence question. Whether these operators land on F1, F3 or Business Premium is unresolved — they share a terminal account but need Business Premium for the Access-based quality apps — and that argument has no bearing on how an individual authenticates: they tap a badge.

Linking a provisioned hire to their account (2026-08-12)

A hire row is created by the HR bot at submission, before any account exists, so entra_id and initials are necessarily empty then. Nothing used to fill them in afterwards — they were writable only by hand — so a hire could complete the whole pipeline while the record of what was created stayed blank. Kurt Leatherman (PSI-1029) was the first hire to get all the way through, and his Journey read “Entra id: not linked · SAM —” beside a live, licensed, group-mapped account.

It was not cosmetic. loginCandidates() tries initials first and falls back to the UPN local part, so with no SAM a hire whose workstation login is KLE can never match a fleet login — and the Journey reports “no workstation assigned to their login” for a machine sitting on their desk.

Three parts, all in server/pcstore/:

  • The Entra object id is captured where the app already reads /users (connectors/entradirectory.js selects id) and stored on pcs_person_directory. It is the stable identity: a UPN changes when somebody’s name changes, an object id does not. COALESCEd on update, because the User Explorer feed posts rows with no id and a client-side refresh must not blank what the scheduled sync captured.
  • linkHires.js sweeps hires missing either half and fills them from the directory — a local join, no second Graph reader. Matches employee_id first (HR’s own key, stable across name changes and rehires), then UPN, preferring an enabled account. A hire matching neither is left alone and reported: guessing a person from a display name links the wrong account, which is worse than no link. It never overwrites a value a human set, and it writes a pcs_hire_event so the field has a provenance.
  • It refreshes the directory once when a matched row predates the object-id column, because that sync is twice-daily — otherwise a hire gets their SAM in minutes and their object id up to twelve hours later. Self-limiting, and shares the scheduler’s single-flight lock.

Scheduled every 15 minutes (ONBOARDING_LINK_MINUTES) with a staggered run at startup, so it heals hires already in that state. Verified on Kurt: the sweep ran nine minutes after deploy and wrote entra_id = 0e83903a-…; initials = KLE (matched KLeatherman@progressivesurface.com).

People pickers — no field holding a person is typed (2026-08-18)

Every person-shaped field on IT Ops is a lookup. A text box holding a person is four spellings and a typo that join to nothing, and manager_upn is written into AD by the provisioner, so a mistyped one produces a hire whose manager is nobody.

One component (components/PersonSelect) and one search (filterPeople — all words must match across name / UPN / SAM / title / department). Which roster a field draws from is declared on its FieldSpec as people: 'directory' | 'itops':

RosterSourceUsed by
directoryevery enabled Entra account (pcs_person_directory)Manager, the hire’s own UPN, the runway’s hiring-manager picker
itopsGET /api/itops/people — the access group, transitively, + bootstrap UPNsOrientation Assigned to, Assign this onboarding

The split is a real distinction: a manager is whoever the manager is, while an assignee has to be somebody who can open the tool. Every itops picker offers Search everyone instead — an onboarding can legitimately be owned from outside the group, but that is the exception rather than the default it used to be.

When the group cannot be read (source: 'unavailable') the picker falls back to the full directory with a note, because a picker offering nobody is indistinguishable from broken and blocks work that has nothing wrong with it. A genuinely empty group is a different answer and is shown as one. Stored UPNs render as the person’s name, with the address as the tooltip.

“What computer does this person need?” (2026-08-18)

The equipment dialog was “Pick a bundle”, and a bundle is our word for a shopping list — so the only answer it could take was one of those lists. The commonest real answer on a shop floor is they use a shared station, which lived in a ⌘K entry (Mark: no equipment needed) nobody looking at an equipment dialog would find. Both answers are now in the dialog; the shared option writes equipment_not_required, satisfying all three equipment steps honestly. Choosing shared does not clear a previously picked bundle, so switching back is not a re-decision.

Setting a journey step by hand, and finishing an onboarding (2026-08-18)

The Journey timeline reads account / licence / badge / device live from Graph and the fleet, which is right for a greenfield hire and wrong for the ones that do not fit that shape. A temp-to-hire who already carried a badge before they had an account has an empty progressivesurface_badgeUid, so Badge enrolled can never go green; somebody who works a shared station has no fleet row resolving to their login, so Device assigned can never go green either. Two of the six scored steps, permanently false, on a person who is fully set up.

Every step now carries a set by hand… control (and a ⌘K entry per step). Two states:

StateMeansTimeline shows
Already doneIt happened; the system cannot see itmarked done by <who> — "<reason>"
Doesn’t applyIt never will for this personnot applicable (<who>) — "<reason>"

A reason is mandatory and is enforced server-side. by / at are stamped from the bearer token and the server clock and are never accepted from the request body; an entry whose state and reason are unchanged keeps its original stamp, so an unrelated save cannot re-attribute somebody else’s override. The override’s own timestamp is deliberately not shown as the step’s date — when it was asserted is not when the thing happened. Clearing an override puts the live answer back.

Stored as pcs_hire.milestone_overrides (JSON keyed by milestone). This is the general form of equipment_not_required, which stays as-is for the equipment pair.

Onboarding can be marked complete. POST /api/pcstore/hires/:id/complete sets status='done' plus completed_at / completed_by in one statement and writes a completed event; {"complete": false} re-opens and clears the stamp. Completion is not gated on the milestones — the hires that need it most are the ones whose steps cannot go green — but the confirm names what is still open, counting unread steps as open. A completed hire leaves the runway and the at-risk digest.

The at-risk digest reads the escape hatches too. It previously read neither: a hire with equipment_not_required set was reported to the IT Cases chat every day as “no bundle picked”, despite the schema comment promising the opposite. settled() in atRiskDigest.js now honours both the flag and per-step overrides. An alert that contradicts the page it links to trains people to ignore the alert.

Adding and signing a policy row without the editor

Two scripts stand in for the editor (#211) and run through the Provisioning maintenance workflow under the runner’s managed identity, so they need nobody’s personal Azure elevation:

ScriptDoes
add-title-rule.js --spec server/provisioning/rules/<file>.jsoncreates/updates a title rule from a reviewed JSON spec
confirm-policy-rows.js --dept <code> / --rule <id>signs a row off (confirmed_revision = revision)

The rule is data in git so what a hire gets handed unattended arrives as a reviewable diff. The script refuses a group name that appears nowhere else in the matrix unless --allow-new-groups — a typo becomes a task that fails against AD — and it leaves the rule unconfirmed, because signing is a separate deliberate act. First use: 125-quality-engineer.json, mirroring the one person holding that title (Dave Klein), after his three unfamiliar groups were each verified in the directory. Its dry run refused until they were.

Running policy operations (provisioning-maintenance workflow)

One-off edits to the policy tables — marking titles badge-only, a corrective data fix — run through the Provisioning maintenance workflow (manual dispatch, psi-portal), never from a workstation.

The reason is not convenience. On 2026-08-11 an apply failed halfway with Login failed for user '<token-identified principal>': the operator’s Azure elevation had lapsed mid-task. The token was still valid, still the right tenant, the account was still Global Administrator — and it held zero Azure RBAC assignments where it had held them four hours earlier. Just-in-time elevation expires, and nothing appears in the Entra directory audit when it does, because ARM role activations are not directory events.

The runner has what is needed permanently: az login --identity, and both runner MIs hold reader/writer on alerts (grant-runner-mi-all-dbs.sql, 2026-08-04) — the same mechanism the deploy job applies schemas with. So no personal elevation, no credentials on anyone’s machine, and the workflow run log is the audit record: who dispatched it, with what inputs, and the script’s full output.

Guards: dry run unless confirm is typed as exactly APPLY; script is a choice list rather than free text, so it cannot become “run arbitrary node on the runner against production”; concurrency group of one, never cancelled mid-write. It is not part of the deploy job, because these are one-off data edits — one that re-ran on every deploy would silently revert whatever IT changed in the editor afterwards.

Seeding and import

scripts/import-provisioning-matrix.js loads server/provisioning/seed-matrix.json + seed-hr-org.json (18 departments, 8 rules, 425 group rows, 79 title refs). Schema is applied by npm run — the portal’s managed identity cannot issue DDL, so server/provisioning/schema.sql runs from CI, not from the app.

Backup Posture

The calibration module is currently pre-production and the CalibrationPortal database has been right-sized to the cheapest viable tier:

SettingValue
TierBasic (5 DTU, 2 GB max)
Backup storage redundancyGeo (Azure default)
Short-term retention7 days (Azure default for Basic)
Long-term retentionDisabled

When the calibration module is promoted to a production workload, this DB should be moved back to General Purpose Serverless and LTR re-enabled (weekly/monthly/yearly retention as required by the data steward).

Network Architecture

Internet (blocked)
       │
       ╳ (Public access denied)
       │
┌──────┴────────────────────────────────────────┐
│         Azure App Service: psi-portal          │
│         portal.progressivesurface.com          │
└──────┬────────────────────────────────────────┘
       │
       │ Private Endpoint (10.160.0.6)
       │ PS-SERVERS subnet
       │
┌──────┴────────────────────────────────────────┐
│              PSI Internal Network              │
│                                               │
│  ┌─────────────────────────────────────────┐ │
│  │ Users access via:                          │ │
│  │ • Onsite workstations                    │ │
│  │ • VPN connection                         │ │
│  │ • M365 App Launcher                      │ │
│  └─────────────────────────────────────────┘ │
└────────────────────────────────────────────────┘

Applications Listed

The portal displays tiles for the following external applications and internal sub-apps — all in one flat alphabetized grid on the home page (since 2026-07-16).

External application tiles

AppCategoryStatus
BOM Explorer (PSI Explorer)EngineeringLive
Project ExplorerEngineeringLive
Redbook DashboardQualityLive
Redbook AnalysisQualityLive
Customer Service ManagerQualityLive
ERP Migration ToolData & IntegrationLive
UniData APIData & IntegrationLive
UniData Sessions & LicensesIT OperationsBeta
PSI WikiAdministrationLive

Internal sub-apps hosted in this portal

Sub-appRouteVisibilityStatus
Procisely admin/procisely/*All authenticated usersLive (production)
Calibration Portal/quality/calibrationAlpha-gated (workbook on U:\ is still SoR)Alpha — pre-cutover
Calibration Labels/quality/calibration-labelsAll authenticated users (open 2026-05-20)Live
ZPL Lab/quality/zpl-labLinked from Calibration Labels; no tile/navDev/diagnostic
Label Designer/quality/labelsAll authenticated users (open 2026-05-20)Beta
CapEx Portal/finance/capexAlpha-gated (single UPN)Alpha — dormant
Customer Data Reconciliation/finance/reconciliationAll authenticated usersLive
User Explorer (People)/it/people (/users redirects)ItOpsGuardLive — inside IT Ops
Purchase Requests/purchasing/requestsAlpha-gatedAlpha — web PR entry
PC Store & Fleet/it/pc-storeEntra-group gated (IT)Alpha — see PC Store section
Timesheet Tester/tools/timesheetAlpha-gatedAlpha — diagnostic
Badge Provisioning/admin/badgesDirect URL only (nav tile removed)Live (operator tool)

Deployment

CI/CD Pipeline

Production deployment runs through GitHub Actions using identity-based Azure login on the PSI runner:

  1. Checkout code
  2. Setup Node.js 20
  3. Install dependencies (npm ci)
  4. Build (npm run build)
  5. Deploy to Azure Web App via az login --identity

Local zip deploy / publish-profile style production deployment is not the compliant path for this app.

M365 Integration

PSI Portal is registered as an Enterprise Application in Entra ID:

Users can access the portal from:

  • Direct URL
  • M365 app launcher (waffle menu)
  • my.apps.microsoft.com portal

Local Development

# Clone repository
git clone https://progressivesurface.ghe.com/ProgressiveSurface/psi-portal.git
cd psi-portal
 
# Install dependencies
npm install
 
# Start dev server
npm run dev
# Opens at http://localhost:5173
 
# Build for production
npm run build

Design System

The portal uses the canonical PSI Design System (C:\git\psi-design-system\assets\ps.css), consumed as the published @progressivesurface/ui package (imported as @progressivesurface/ui/css in main.tsx; the previously vendored src/styles/ps.css copy is gone). The brand color is #027A54 (Pantone 348 green) — not blue. Tailwind utilities (bg-psi-green, text-psi-ink, border-psi-border, and the semantic banner/chip triads bg-psi-{success,warning,danger,info}-bg / -fg / -border) bridge to canonical --ps-* tokens in the @theme block of src/index.css.

Color Palette (canonical)

TokenHexUsage
--ps-green-500027A54Brand primary — headers, accents, focus rings
--ps-ink(dark)Primary text — body, headings
--ps-ink-3(mid)Secondary text — labels, descriptions
--ps-border(light)Surface borders
--ps-surface(white)Card / tile background

A back-compat bridge maps the legacy --color-psi-blue name to the green ink token, but do not use hardcoded hex in new code. Prefer the ps-* / psi-* Tailwind utilities or var(--ps-*) tokens.

Usage in components:

<header className="bg-psi-green text-white">
  <div className="p-3 bg-white/10 rounded-xl">...</div>
</header>

Future Enhancements

  • Search functionality for apps
  • User favorites/pinned apps
  • System status from health endpoints
  • Theme toggle (light/dark mode)
  • Notifications for new apps/updates