One badge vocabulary, and images a browser can actually load

Two operator-visible defects and three doc corrections, closing the tail of the
2026-09-03 run.

THE BADGE. The two kiosks read the same physical badges and disagreed. The
parts kiosk understood both shapes a scanner emits - the bare SSO and the PayNo
wrapper 0<digits>BZ - while the USB kiosk compared the raw string to an SSO, so
the wrapper resolved to nobody. An operator whose keyboard-wedge scanner emits
it could take a 3D-printed part and could not check out a drive, with the same
badge, at two cabinets a few metres apart.

The shape now lives in shopdb/utils/badges.py on the contract surface, because
a plugin may not import another plugin and because a badge is a property of the
site rather than of a cabinet. Both kiosks use it, and both store the DIGITS
rather than the string that arrived: one row per spelling makes a custody
history that cannot be joined to itself.

That closed a subtler hole in the same place. str.isdigit() is true for
Arabic-Indic and other decimal forms, and int() parses them, so a badge written
in those characters was accepted, matched a REAL employee, and was then stored
as an SSO string nothing else matches. ASCII digits only. An empty scan and an
unreadable one now get different answers, because they are different problems
for the person at the cabinet.

THE IMAGES. /api/applications/image/<filename> and /api/models/image/<filename>
required a permission, and a browser fetching <img src="..."> sends no
Authorization header - so the gate did not protect the file, it guaranteed the
picture never appeared. Both are ungated now, like the four sibling image routes
(branding logo, map blueprint, employee photo, part picture), and registered in
PUBLIC_ENDPOINTS with that reasoning.

/api/printers/lookup?fqdn= never matched anything: both arguments were compared
against Communication.ipaddress, so a hostname lookup 404d on printers whose
hostname was on the record. An FQDN now matches the hostname, with a fallback to
the short name.

The slide manager gained the per-slide hold time. The PATCH route and the feed
have always carried `seconds`; nothing called it, so the one documented
per-slide control was curl-only.

Docs: API-REFERENCE listed the employee lookups and the whole notifications
surface as unauthenticated when they answer 401, and called both kiosks
"deliberately open" when they are off by default and refuse anything without the
site's device token. The installer download says applications.download, which is
what the code requires. api-inventory.json said printers/lookup was
jwt-optional.

Fourteen tests. The badge rule verified by planting the old per-kiosk behaviour:
three fail, one in each kiosk.

Scope: product
This commit is contained in:
cproudlock
2026-09-03 16:25:08 -04:00
parent 8fee09e748
commit cbdfcbabb7
13 changed files with 288 additions and 43 deletions

View File

@@ -109,7 +109,7 @@ carries value or liability of its own:
| Endpoint | Requires | Why |
|---|---|---|
| `GET /api/applications/package/<filename>` | `applications.view` | Licensed vendor installers. An anonymous URL is a redistribution channel. |
| `GET /api/applications/package/<filename>` | `applications.download` | Licensed vendor installers. A separate permission from `applications.view` on purpose: seeing that an application exists is not the same as being handed the vendor's installer, and an anonymous URL is a redistribution channel. The script-body download is gated the same way. |
| `GET /api/warranty/proof/<filename>` | `warranty.view` | Invoices and certificates carry pricing and a service tag. |
Their sibling upload/delete routes are `applications.edit` / `warranty.edit`
@@ -135,14 +135,23 @@ like any other mutation.
| `GET /api/plugins/enabled` | List enabled plugins (no claims used). |
| `GET /api/dashboarddefaults/display-role` | Display role resolution (see section 1). |
| `GET /api/dashboarddefaults/visitor-location` | Lobby business-unit resolution (see section 1). |
| `GET /api/employees/search`, `/lookup/<sso>`, `/lookup` | Employee directory lookups (kiosk / sign-in flows). |
| ~~`GET /api/employees/search`, `/lookup/<sso>`, `/lookup`~~ | NOT public. These need `employees.view` and answer 401 anonymously. A kiosk resolves a badge through its own gated endpoint, not through these. |
| `GET /api/employees/photo/<filename>` | Serve an employee photo. |
| `GET /api/notifications` (and `/types`, `/<id>`, `/active`, `/calendar`, `/dashboard/summary`, `/employee/<sso>`, `/shopfloor`) | Read shop-floor notifications for the public display and kiosks. |
| `GET /api/notifications/shopfloor`, `GET /api/notifications/employee/<sso>` | The two the public display and kiosks actually read. The rest of `/api/notifications` (the list, `/types`, `/<id>`, `/active`, `/calendar`, `/dashboard/summary`) needs `notifications.view` and answers 401 anonymously. |
| `GET /api/slides/feed` | Slide feed for the lobby display and screensaver. |
| `GET /api/slides/img/<surface>/<filename>` | Serve a slide image. |
| `GET /api/printedparts/image/<filename>` | Serve a printed-part image. |
| `GET /api/printedparts/kiosk/item/<itemcode>` | Kiosk part lookup (deliberately open; a kiosk carries no JWT). |
| `POST /api/printedparts/kiosk/take` | Kiosk part checkout (deliberately open, per decision record). |
| `GET /api/printedparts/kiosk/item/<itemcode>` | Kiosk part lookup. Carries no JWT, but is NOT open: the routes 404 unless `printedparts_kiosk_enabled` is on AND the request carries the site's `X-Kiosk-Token`. |
| `POST /api/printedparts/kiosk/take` | Kiosk part checkout, behind the same flag and device token. |
| `GET /api/usb/kiosk/device/<device_id>` | USB kiosk device lookup, behind `usb_kiosk_enabled` plus `X-Kiosk-Token`. |
| `POST /api/usb/kiosk/checkout`, `/checkin` | USB kiosk custody moves, same gate. |
**"No authentication" is not "open."** Both kiosks are off by default and refuse
every request without the site's device token; with the flag off they answer 404
rather than 403, so a kiosk a site has not turned on does not announce itself.
The token is a credential for a screen, not authorization for a person: it says
which cabinet is asking, and the badge on the request says who is standing at
it.
---

View File

@@ -3235,8 +3235,8 @@
{
"method": "GET",
"path": "/api/printers/lookup",
"auth": "jwt-optional",
"params": "ip or fqdn (one required; value matched against communication ipaddress)",
"auth": "printers.view",
"params": "ip or fqdn (one required; ip matches a communication address, fqdn matches the printer's hostname)",
"purpose": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).",
"example": "curl 'http://localhost:5001/api/printers/lookup?ip=10.1.2.42'"
},
@@ -4215,4 +4215,4 @@
}
]
}
]
]