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>