The métier PWA — your data in your pocket¶
The hub rendered on a phone. /app/ is a small Preact app served by the hub itself —
no app store, no build step, no new endpoints: it speaks the same scope-bound API that
lm does, and installs from the browser like any PWA. One deployment carries every
métier; the ?m= parameter decides whose app it is.
The UI speaks French — it is built for the métier user, not the integrator.
Reaching it¶
https://<your-host>/app/ # the clients métier (default)
https://<your-host>/app/?m=stock # the stock métier
Each métier in the registry carries its own title, home-screen icon, and web manifest
(manifest-clients.json, manifest-stock.json), swapped in before first paint — so
"Clients" and "Stock" install as two distinct apps on the same phone. An unknown or
absent ?m= falls back to clients; it never errors.
What a métier user actually sees inside is decided elsewhere: by the saved views the
integrator has applied for that scope, and by the scope:<tag> role on the
user's account.
Installing it on a phone¶
The PWA needs a secure origin: service workers and the real install prompt only
exist over https. The self-host stack ships a TLS façade for exactly this —
one Caddy origin fronting hub and Keycloak, with automatic Let's Encrypt for a public
LUMNIK_TLS_HOST (see deploy/tls/README.md). On a LAN address Caddy uses its
internal CA; trust its root on the device or installability is degraded (the
browser's rule, not ours).
Then, on the phone:
- Open
https://<host>/app/?m=<métier>and sign in. - iOS Safari: Share → Add to Home Screen. Android Chrome: the install prompt, or ⋮ → Add to Home screen.
- The icon and name are the métier's own; the app opens standalone, no browser chrome.
The three tabs¶
Same three surfaces on every device — bottom tabs on a phone, a left rail on a display.
Fiches — the saved views¶
The tab discovers its views from the hub (GET /api/views?scope=<métier>), then fetches
the chosen manifest by name (GET /api/views/{name}): one view is opened directly, several
offer a picker, none is an honest amber message — the integrator must
POST /api/views/apply one first (or lm view apply -f). Rendering and filtering go through
the same POST /api/views/render as lm view, with the same
honesty rules: filters are sent raw, the hub is the one
referee, and a refused filter keeps the last good result on screen with the refusal
shown under the filter bar. Tapping a row opens the detail
(POST /api/views/detail) — including the _conflicts ledger: which source disagreed
on which field, or an explicit "all sources agree".
Décisions — the inbox¶
The inbox fold (GET /api/inbox), newest first, with the ack gesture —
✓ Traiter posts POST /api/inbox/{id}/ack, the same idempotent ack-is-an-event as
lm inbox ack. The unacked count rides as a badge on the tab from the first frame.
Tenant-wide, for now
Decisions are not yet bounded by métier — the tab shows the whole tenant's decisions and says so in its footer. Per-métier bounding is a known follow-up.
Chat — ask your data¶
The guarded analytic ask, through the lm_user door (POST /api/rag/chat, analytic
mode, bound to the métier's scope). An answer always shows the SQL it ran and the row
count, folded under the reply. An answer that ran no SQL is an abstention and wears
the amber refusal tone — the same honesty contract
as everywhere else. The thread survives tab switches but not a reload; nothing is
stored.
Responsive modes¶
One codebase, three postures — CSS breakpoints, not separate apps:
| Width | Fiches | Navigation | Detail |
|---|---|---|---|
| phone (< 640px) | cards | bottom tabs | bottom sheet |
| tablet (≥ 640px) | table | bottom tabs | bottom sheet |
| display (≥ 900px) | table | left rail | pinned side panel |
Identity & scope¶
The PWA is an OIDC public client: lumnik-spa, authorization code + PKCE (S256)
against the realm. Tokens live in sessionStorage for the tab's lifetime; a silent
refresh renews them ~60 s before expiry; logout is RP-initiated (Keycloak session
ends too, not just the local one). Under the TLS façade, Keycloak is reached
same-origin at /realms/lumnik — one issuer for every device.
External IdP: lumnik-spa needs an audience mapper
The hub requires aud=lumnik-backend on every token. The bundled realm carries
the mapper for lumnik-spa; if you broker or replace Keycloak with your own IdP,
the SPA client must add the same audience mapper — without it, every PKCE token
is a plain 401 at the hub, with nothing wrong visible on the login side.
(Learned live: see docs/studies/2026-07-11-mobile-metier-renderer-study.md.)
Access is the métier-scope boundary, nothing PWA-specific: the user needs the
scope:<tag> role for their métier. Views, entities, and chat all refuse uniformly —
403 {"error":"scope not granted"} — and the PWA renders that as the amber honest
refusal, never a broken screen.
Offline honesty¶
The service worker caches the app shell only — HTML, CSS, modules, icons. Data is
never cached: everything under /api/* and /realms/* bypasses the worker
entirely and always hits the network. Tenant data and tokens never touch disk.
So offline, the app opens instantly — and says the truth: an "Hors ligne" screen with a retry button, instead of stale rows pretending to be fresh. When the network returns, so does the data.
Deploying a shell change
The browser only re-installs the shell when sw.js itself changes bytes. Every
shell-file change must bump the CACHE version in app/sw.js — otherwise
returning visitors keep the old shell forever.
Adding a métier¶
A métier is an entry in the app's registry, not a deployment. Today the registry is
part of the hub's baked-in app sources
(lumnik-hub/src/main/resources/META-INF/resources/app/), so adding one is a source
change plus a hub rebuild:
- Manifest + icons —
manifest-<tag>.json(name,start_url: /app/?m=<tag>) and itsicon-<tag>-192/512.png. - Registry entry — add
<tag>toMETIERSinsrc/config.mjs(scope, title, manifest, icon). - Shell list — add the new files to
SHELLinsw.jsand bumpCACHE. - Server side — apply at least one saved view for the scope
(
POST /api/views/apply) and grant the users theirscope:<tag>role in Keycloak.
Without step 4 the app still opens — Fiches honestly says no view is applied, and every scoped call refuses with the uniform 403.
See also¶
- kind: View — the manifests the Fiches tab renders.
- Inbox — the decisions fold behind the Décisions tab.
- Scopes — the boundary the whole app lives within.