A till signs in with a mobile number and a PIN, but createposuser still
accepted an account without a number. Such an account cannot reach the
sign-in screen at all, and the failure surfaces at a counter in front of
a queue rather than at the point of creation. Enforced in CreatePosUser,
so both doors are covered: POST /pos/users from the terminal and
createposuser from the console share that path.
Two checks, not one. normalisePosPhone answers ("", nil) rather than an
error for a value holding no digits, so "abc" would have passed an
emptiness check, then been written as a blank and skipped the uniqueness
check below it — which is the hole this closes.
Scope is new accounts only. The column stays nullable and UpdatePosUser
still reads an empty contactno as "leave alone", so the accounts that
predate the number keep working through the backfill and cannot have
theirs cleared. The PIN stays optional at creation.
Also in this change:
- docs: correct both phone-login handovers, which claimed creation
already required a number. The sequencing note in the PIN handover
said step 2 was a backfill that could never be finished; it now is
one, and POS_LOGIN.md says which half of the pair creation enforces.
- docs: remove credentials from the examples. POS_PHONE_LOGIN_HANDOVER
carried a real-looking back-office pair and a generated till password,
and POS_LOGIN.md a second one.
- posController.Staff: the comment justified scoping by token because
"the answer carries PINs". It has not since the PIN left the wire. The
scoping is still right for a different reason, which the comment now
gives.
- scratch/posstaffsetup: takes both mobile numbers as arguments and
refuses to run without them. Generating stand-ins would have produced
exactly what this change prevents. Validated before the database is
opened, in plan mode too, so a dry run cannot print a plan that apply
would reject halfway through and leave half a shop set up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The outlet check could not go in the middleware with the others: it scopes a
request by the location it names, and this route names only a terminal code —
free text minted at the till. The outlet is not known until after the lookup,
so the check happens in the handler, reading the outlet off the heartbeat
itself rather than off the request.
Guessing "T4A9" now answers 403 instead of another shop's pending bills,
takings so far today, and app version. Silent when no token is presented, in
step with middleware.PosAuth: while POS_AUTH_REQUIRED is off, real tills are
still calling this unauthenticated and refusing them would blank the fleet
board for exactly the terminals it watches.
scratch/termbackfill repairs the rows left behind by the posTerminalFor bug.
The code was never lost — it is the third segment of the invoice number the
till printed in the same transaction — so this derives rather than guesses,
and refuses to write a code that outlet has never filed a bill under. 39 of
40 recovered; the holdout is a synthetic proof bill whose only sibling is
itself, and unverifiable codes stay blank rather than becoming plausible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The supervisor blurb still said "Also signs into the app", which was true when it
was written and is now the opposite of true: a till account has no Nearle Daily
login at all. A console showing that text would be telling a store admin the one
thing about these roles they most need not to believe.
The cashier blurb said "Billing only", which understated it in the other
direction — a cashier now has their own username and password, so a shop can
open without a supervisor standing there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A PIN cannot open a closed terminal. The PIN route needs a session that already
exists, so a PIN-only account works only while somebody else is standing there
to unlock the till first. For a supervisor that was an outright deadlock and was
fixed last commit. For a cashier it is subtler and just as wrong: the shop
cannot open until two people have arrived, and whoever gets in at seven is as
often the cashier as the supervisor.
So every till account now gets a username and a password, and the role decides
the shell rather than the credential deciding it. A cashier signs in exactly the
way a supervisor does and is still held to billing only, because that comes from
roleid 8 and not from how they got in.
The earlier reasoning — that a second password is one more credential to leak
for no capability gained — was measuring the wrong thing. It counted the cost of
the credential and not the cost of the shop that cannot open without one.
CreatePosUser generates both when the request omits them, so provisioning is one
call per person and nobody has to invent a naming scheme. An explicit value
always wins. A generated name that collides walks to the next free one, because
a second cashier at one counter is ordinary rather than an error; a name the
caller supplied is refused instead, because silently signing somebody in as
another person's address is worse than a message. Uniqueness is checked against
authname and email together, since the insert writes the same value to both and
app_users_email_unique would otherwise fail the transaction rather than return
something anyone can act on.
The password comes back exactly once, in the creation response. Listing till
users still reports only has_password, so an admin who loses it reissues rather
than looks it up — the right shape even while the column behind it is plaintext.
The domain is deliberately unroutable. These are till credentials, never a
mailbox, and an address that looks deliverable invites somebody to try sending a
reset to it.
Verified against live rows by scratch/posseparation, which now checks the
cashier path too: cashier.1185@pos.nearle.in opens a closed terminal alone and
comes back can_manage_staff=false. All five outlets that stock products have
both accounts, each proved by an actual sign-in.
Also drops a stray `print(queryBuilder.String())` from GetAllUsers, which was
writing the whole SQL statement to stderr on every call.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
app_users is the only thing the two products have in common, and the code was
treating it as though it were the whole relationship. Both directions leaked.
Back-office roles were leaking into the till. PosRoleCanManageStaff returned
true for roleid 1 to 6, on the reasoning that somebody who already administers a
shop from a browser is not made less privileged by standing at the counter. That
sounds fine and is wrong: measured against live data it handed till-supervisor
powers to 68 accounts, 59 of them Nearle Daily Super admins, not one of whom is
the administrator of anybody's POS. Meanwhile the actual shop accounts carry
roleid 0 and were refused, so the mapping was backwards from intent in both
halves at once.
Till accounts were leaking into the application. GetStaffs is WHERE tenantid
with no role filter, so a Counter Cashier appeared in the tenant staff list
beside the delivery riders — a row every action on that page would fail against,
since a cashier has no app login, no rider shift and no back-office screen.
So: eligibility for a till is now granted explicitly by provisioning a
Supervisor or a Cashier, never inherited from a back-office role, and roles 7
and 8 are excluded from every Nearle Daily lookup. The exclusion lives in the
queries rather than in a check after them, because a check bolted on afterwards
has to be repeated at six call sites and is one edit away from being forgotten
at one of them — and that one would be the hole. A till account is not rejected
by the app login; it is not found.
Two things this surfaced that were not visible before.
A Supervisor could not open a till. PIN sign-in needs a session that already
exists, so once back-office roles were refused, an outlet whose only POS
accounts were PIN-only had no way in at all. Supervisors are now provisioned
with a username and password as well as a PIN; cashiers deliberately get neither,
because they sign on at a counter somebody has already opened and a second
password would be one more credential to leak for no capability gained.
UpdatePosUser silently dropped authname. It wrote the password, reported
success, and left the account unreachable by either lookup — the failure
surfaced at a counter as "not recognised" rather than on the screen that caused
it. Contactno had the same gap.
Verified against live rows rather than asserted, by scratch/posseparation: a
provisioned supervisor signs in and gets the supervisor shell; five real
back-office accounts including Super admins are refused; the supervisor is
invisible to applogin, tenant weblogin and the password-setup lookup; and no
till account appears in getallusers, while asking for role 7 by name still
returns them so the console can read its own people.
All five outlets that stock products now have a Supervisor and a Cashier.
Also moves the loose markdown into docs/, which was already staged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers the question a shop asks on day one and nothing in the product could:
what do I type into the terminal. It separates the two credentials because they
are not interchangeable — a password opens a closed terminal and the account
decides which shell it opens, a PIN only switches operator on one already open —
and it prints the shell each account would land in rather than the raw roleid,
since roleid 0 is not in app_roles and reads as nothing at all.
`top` ranks outlets by products actually stocked *and* filters to ones somebody
can sign in to. That filter is the point: the best-stocked outlets on the
platform — Dilse at 471 products, Ninhao at 286 — have no account with a
password, so a demo pointed at either opens a till nobody can unlock.
Counts products through productlocations rather than per tenant, because a
tenant with a full catalogue can still have an outlet stocking none of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Two paths write `app_users`: the console's `tenants/createstaff`, and the
terminal's `/pos/users`. Only one of them checked anything.
`createstaff` wrote whatever it was handed. A cashier could be created there
with PIN "0451" — which a bigint column stores as 451 — and would then type four
digits at the counter and be refused for ever, with nothing on either screen to
explain it. Or with 1234, which live data already has on eleven accounts. Or
with a PIN somebody at the same outlet already had, which attributes a bill to
whichever row is read first. Or with no way to sign in at all.
None of that surfaced where it was caused. It surfaced at a counter, days later,
as "the new person cannot log in".
So the rules move into `ValidateStaffUser`, and both paths use it: a name, a
role that is actually a role, a PIN the schema can hold and nobody guesses
first, and at least one way to sign in. The duplicate-PIN check runs too, when
the row names an outlet.
The handler also stops answering 500 with a body claiming 409. Every one of
these is something the person filling in the form can fix, so it is a 400
carrying the reason.
`GetStaffs` now returns `rolename` alongside `roleid`, so a console can show
"Supervisor" without mapping ids itself — `app_roles` has six rows for four
back-office roles and most accounts carry an id absent from it, so any mapping
written client-side would be wrong.
This is what makes the two role systems one. A supervisor or cashier created
from the web behaves at the till exactly like one created at the till, because
there is now a single definition of what those are.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`app_users.userid` is a `GENERATED BY DEFAULT AS IDENTITY` column. It did not
look like one: `information_schema.columns.column_default` is empty for identity
columns, and reading that as "no default at all" is how this came to compute its
own id with MAX+1.
That worked, and quietly did the wrong thing. An explicit id does not advance
the sequence, so two allocators ended up running in parallel — the sequence sat
at 1447 while MAX(userid) had reached 9189. They cannot collide today, because
almost nothing occupies the range between, but they converge on every insert and
the first collision would be a primary key violation on a live sign-up.
The insert now omits userid and reads it back with RETURNING. The advisory lock
stays, because it was never about the id: two supervisors adding staff at the
same instant could both find a PIN free and both take it, and a duplicate PIN
attributes a bill to whichever row is read first.
Also documents why the email columns go through NULLIF. `app_users_email_unique`
is real, and a second cashier created without an email would otherwise collide
on the empty string — while NULLs do not collide in Postgres. A cashier who
signs in by PIN alone has no email, which is the common case rather than the
edge one.
Verified in a rolled-back transaction against live data: creation allocates
1448, the sequence advances 1447 -> 1448, a second emailless user is accepted,
and a duplicate PIN is still refused.
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>