An admin sets a shop up from a browser; a supervisor adds a cashier at the
counter. Both had to be possible, and only the second one was.
So the console gets createposuser / updateposuser / getposusers /
deleteposuser, under both /v1/web/tenants and /v1/mob/tenants — calling the
same service methods `/pos/users` calls. Not a parallel implementation: a
supervisor created from a browser is the same row, with the same PIN rules, the
same duplicate check and the same identity-column allocation, as one created at
a till. Two paths writing one table is precisely how the two stop matching, and
this codebase already had that happen once.
configid is inferred rather than asked for. It is a number nobody looks up, it
varies per tenant — 1087's accounts are spread across 1, 6 and 15 — and getting
it wrong creates somebody who cannot sign into the portal their colleagues use
and is invisible to half the platform's queries.
/posroles is served rather than left to the console to hardcode. A console that
knew supervisor was 7 would be wrong the day that changed and would have no way
to find out.
The outlet is the real difference between the two doors. A terminal proves it
with a signed token; the console asserts it, and is checked against the tenant
before anything is written. That is weaker, and it is worth being plain about:
these mint till credentials on an unauthenticated request, exactly like every
other route in the /v1/web and /v1/mob groups, because there is no auth
middleware on the web API at all. Documented as the weakest point in the design
and flagged to move behind a session guard once the console can hold one. The
terminal routes are untouched by it.
Proven in a rolled-back transaction against live data: the console creates a
supervisor at 1135, that supervisor signs in by PIN with can_manage_staff true,
the till's /pos/staff sees them alongside the two created at the counter, and
0451, 1234 and a duplicate PIN are each refused with the same message the
terminal gives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shop had no way to add the people who work in it. The terminal fell back to
three names and three PINs compiled into the app — the same three on every
install — because there was nothing for it to fall back *from*.
Two roles now exist in `app_roles`: Supervisor (7) runs the terminal and creates
staff, Cashier (8) bills. Fixed ids, written by hand, because that table has no
sequence and every id in it was assigned the same way. configid is left NULL
rather than duplicated per portal: a till is a till whichever portal a tenant
uses, and Admin already appears twice in that table for exactly that reason.
`/pos/users` is CRUD over them, and `/pos/login/pin` signs a cashier on at a
terminal a supervisor has already opened.
The rule every one of these follows: **tenant and outlet come from the caller's
token, never from the request.** There is no location field on the create body
to get wrong. A supervisor at Selvapuram cannot create staff at R mart, for the
same reason a till cannot bill into another shop's books — it is the same
inversion applied to people instead of sales.
PIN sign-in is deliberately behind the guard. Four digits is ten thousand
guesses, which is no barrier to an anonymous caller; requiring a session means a
real password opened the terminal first and the guesses are confined to one
outlet's own staff. The session it mints is fresh rather than derived, so a
cashier taking over from a supervisor drops their permissions instead of
inheriting them.
Three things the schema forced:
- A PIN cannot start with zero. `app_users.pin` is a bigint, so "0451" stores as
451 and reads back as three digits — a cashier would type four and be refused
for ever. Live data already holds one such account. Rendering refuses to show
a PIN it cannot represent, rather than showing a short one nobody can type.
- `app_users` has no sequence either, so the next id is read and written inside
one transaction behind an advisory lock. Two supervisors creating staff at the
same moment would otherwise compute the same id and one insert would lose.
- 1234, 1111 and friends are refused outright. Live data has 1234 on eleven
accounts and 1111 on nine.
Proven against outlet 1135, which had zero staff and was the reason the built-in
PINs were still load-bearing:
created 9188 Store Supervisor Supervisor can_manage_staff=true
created 9189 Counter Cashier Cashier can_manage_staff=false
/pos/staff now returns 2 an unknown PIN is refused
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POS_LOGIN.md is for whoever builds and tests the till: the flow in the order it
has to happen, the three endpoints with real request and response shapes, every
error code with its verbatim message, the multi-outlet picker rules, and how
staff and PINs are meant to be handled.
Three things in it are the ones people will otherwise get wrong. `store_id`
comes out of the login response and is never typed by anyone — that is the whole
change. `staff` is usually empty, including at the outlet this build ships
pointed at, so an empty list has to be a no-op and not a wipe. And enforcement
is currently off, which means an untokened request still works today but a token
that *is* sent is still fully checked.
scratch/liveloginproof signs in against the live endpoint with a password read
out of the database — never printed, never passed on a command line where it
would land in a shell history — and then checks the token opens what it should
and refuses what it should not. The token is truncated in its output for the
same reason: it is a bearer credential for a whole trading day.
Run against v1.3.98 in production:
POST /login 200 token minted, store_id 1135 resolved
GET /session 200
GET /staff 200
GET /catalogue?store_id=1135 200
GET /catalogue?store_id=1185 403 this session cannot reach outlet 1185
POST /health 202
The 403 is the one worth keeping: a valid token, refused at another tenant's
outlet. That is the hole this work existed to close, shut on live traffic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>