Serve a shop's own staff to the till, so the built-in PINs can retire

The terminal shipped with three names and three PINs compiled into it. Same
three on every install, readable by anyone with the APK, and permanent —
nothing anywhere could replace them.

`/pos/staff` answers with the people the back office says may ring a bill at an
outlet, and the same list rides down with the session so a till is ready to
trade the moment it signs in. The terminal writes them over its own and
deactivates whatever it had, which is what actually kills the seeded logins.

Two sources are unioned because the schema has two and neither is complete.
`tenantstaffs` is the table built for this and holds 12 rows on the entire
platform; `app_users.locationid` is where staff actually ended up. Either alone
returns nothing for almost every shop.

The endpoint takes no location parameter. The answer carries PINs, so the
outlet comes from the caller's token and a request without one is refused
whatever POS_AUTH_REQUIRED says — a till must not be able to ask who works at
the shop next door.

Rows with no PIN are dropped rather than sent: a name on screen nobody can sign
in as reads as a broken terminal rather than as an unfinished setup. Duplicate
PINs are dropped too, keeping the first — live data has 1234 on eleven accounts
and 1111 on nine, and two people sharing one would make the till attribute a
bill to whichever row it checked first.

The PIN travels in the clear over TLS, deliberately. Four digits are
brute-forceable in microseconds however they are wrapped, so hashing here would
buy the appearance of strength and not the substance — while costing something
real, since the terminal salts every PIN with its own salt before storing it
and could never verify a hash computed here. A PIN is shift attribution, not a
security boundary; the boundary is the session token.

Verified against live data, and it says the fallback still matters: outlet 1135
— the one the POS actually uses — has zero staff, and the only staff row found
anywhere is a delivery rider on PIN 1111.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-06 16:01:49 +05:30
parent 12165d5e58
commit c4dfcd5387
7 changed files with 156 additions and 0 deletions

View File

@@ -453,3 +453,32 @@ func (ctl *PosController) Session(c *fiber.Ctx) error {
},
})
}
// Staff lists who may ring a bill at this terminal's outlet.
//
// Scoped by the caller's own session rather than by a query parameter. A till
// asking "who works here" must not be able to ask on behalf of another shop,
// and the answer carries PINs — so the outlet comes from the token, and a
// request without one is refused whatever POS_AUTH_REQUIRED says.
func (ctl *PosController) Staff(c *fiber.Ctx) error {
claims, ok := middleware.PosClaimsFrom(c)
if !ok {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": "a session token is required to read staff",
})
}
staff, err := ctl.posService.Staff(claims.Tenantid, claims.Locationid)
if err != nil {
return posServerError(c, "Staff", err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": models.PosStaffResponse{
Locationid: claims.Locationid,
Staff: staff,
},
})
}

View File

@@ -80,6 +80,10 @@ func (f *fakePosService) LocationAllowed(int, int) (bool, error) {
return false, nil
}
func (f *fakePosService) Staff(int, int) ([]models.PosStaffMember, error) {
return nil, nil
}
func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) {
return nil, nil
}

View File

@@ -281,4 +281,46 @@ type PosSession struct {
// gets a list of one, so the till has no special case: it shows a picker
// when there is a choice and skips it when there is not.
Locations []PosLoginLocation `json:"locations"`
// The people who may ring a bill at the chosen outlet.
//
// Sent with the session so a terminal is ready to trade the moment it signs
// in, rather than needing a second call before the first customer. May be
// empty — most tenants have no staff recorded yet — and the terminal has to
// cope with that rather than treat it as a failure.
Staff []PosStaffMember `json:"staff"`
}
// PosStaffMember is one person who may ring a bill at an outlet.
//
// Distinct from the account that signs the *terminal* in. The sign-in says
// which shop this till belongs to; this says who is standing at it, and it is
// what gets stamped on a bill as `cashiername` and settled against at the end
// of a shift.
//
// The PIN travels in the clear, over TLS, and that is a considered choice
// rather than an oversight. A four-digit PIN is brute-forceable in microseconds
// whatever it is wrapped in, so hashing it here would buy the appearance of
// strength and not the substance. What it would cost is real: the terminal
// salts every PIN with its own random salt before storing it, so a hash
// computed here could never be verified there without inventing a shared
// scheme and keeping two codebases agreeing about it for ever.
//
// The honest framing is that a PIN is *shift attribution*, not a security
// boundary. The boundary is the session token — which is what stops a till
// reaching another tenant's books at all. The PIN decides which of the people
// already inside a shop gets credited with a sale, and the terminal still
// stores it hashed at rest.
type PosStaffMember struct {
Userid int `json:"user_id"`
Fullname string `json:"full_name"`
Role string `json:"role"`
Pin string `json:"pin,omitempty"`
Status string `json:"status,omitempty"`
}
// PosStaffResponse answers a request for an outlet's people.
type PosStaffResponse struct {
Locationid int `json:"location_id"`
Staff []PosStaffMember `json:"staff"`
}

View File

@@ -129,6 +129,15 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession
}
r.decoratePosSession(session)
// Staff come down with the session so a till is ready to trade the moment
// it signs in. A failure here is not a failed sign-in: a shop with no staff
// recorded — which is almost all of them today — must still be able to open
// its terminal.
if staff, err := r.PosStaff(session.Tenantid, session.Locationid); err == nil {
session.Staff = staff
}
return session, nil
}
@@ -297,3 +306,61 @@ func constantTimeEqual(a, b string) bool {
}
return diff == 0
}
// PosStaff lists the people who may ring a bill at an outlet.
//
// Two sources, unioned, because the schema has two and neither is complete.
// `tenantstaffs` is the table built for this and holds 12 rows on the entire
// platform; `app_users.locationid` is where staff actually ended up. Reading
// only the purpose-built table would return nothing for almost every shop, and
// reading only `app_users` would miss anyone assigned through the back office's
// staff screen. So both.
//
// Only people with a PIN come back. A row with `pin = 0` cannot ring anything —
// offering it to the till would put a name on screen that no one can sign in
// as, which reads as a broken terminal rather than as an unfinished setup.
func (r *posRepository) PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error) {
rows := make([]models.PosStaffMember, 0)
query := `
SELECT DISTINCT
a.userid,
TRIM(CONCAT(COALESCE(a.firstname,''), ' ', COALESCE(a.lastname,''))) AS fullname,
COALESCE(r.rolename, '') AS role,
CAST(a.pin AS TEXT) AS pin,
COALESCE(a.status, '') AS status
FROM app_users a
LEFT JOIN app_roles r ON r.roleid = a.roleid
WHERE a.tenantid = ?
AND COALESCE(a.pin, 0) > 0
AND LOWER(COALESCE(a.status, 'active')) <> 'inactive'
AND (
a.locationid = ?
OR EXISTS (SELECT 1 FROM tenantstaffs s
WHERE s.userid = a.userid
AND s.tenantid = a.tenantid
AND s.locationid = ?
AND LOWER(COALESCE(s.status, 'active')) <> 'inactive')
)
ORDER BY fullname`
if err := r.db.Raw(query, tenantID, locationID, locationID).Scan(&rows).Error; err != nil {
return nil, err
}
// A PIN shared by two people at one outlet would make the till attribute a
// bill to whichever row it happened to check first — so the second one is
// dropped rather than sent. Live data has 1234 on eleven accounts and 1111
// on nine, so this is not hypothetical.
seen := make(map[string]bool, len(rows))
unique := rows[:0]
for _, row := range rows {
if seen[row.Pin] {
continue
}
seen[row.Pin] = true
unique = append(unique, row)
}
return unique, nil
}

View File

@@ -42,6 +42,7 @@ type PosRepository interface {
// own record, rather than being named by the till and believed.
PosLogin(req models.PosLoginRequest) (*models.PosSession, error)
PosLocationAllowed(tenantID, locationID int) (bool, error)
PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error)
// Reading counter sales back out. Without these a committed bill is
// unreachable from every screen in the product.

View File

@@ -38,6 +38,10 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
pos.Get("/session", f.PosController.Session)
// Who may ring a bill here. Deliberately takes no location parameter — the
// answer carries PINs, so the outlet comes from the caller's own token.
pos.Get("/staff", f.PosController.Staff)
pos.Post("/orders", f.PosController.IngestOrders)
pos.Post("/customers", f.PosController.IngestCustomers)
pos.Get("/catalogue", f.PosController.Catalogue)

View File

@@ -33,6 +33,11 @@ type PosService interface {
// LocationAllowed is the authorisation check every other POS call rests on:
// does the tenant in the caller's token actually own this outlet.
LocationAllowed(tenantID, locationID int) (bool, error)
// Staff lists who may ring a bill at an outlet. Sent with the session and
// available on its own, so a shop that hires someone mid-shift can pull them
// down without signing the terminal out.
Staff(tenantID, locationID int) ([]models.PosStaffMember, error)
}
type posService struct {
@@ -111,3 +116,7 @@ func (s *posService) Login(req models.PosLoginRequest) (*models.PosSession, erro
func (s *posService) LocationAllowed(tenantID, locationID int) (bool, error) {
return s.repo.PosLocationAllowed(tenantID, locationID)
}
func (s *posService) Staff(tenantID, locationID int) ([]models.PosStaffMember, error) {
return s.repo.PosStaff(tenantID, locationID)
}