Files
backend_fiesta/docs/POS_LOGIN.md

23 KiB
Raw Blame History

Nearle POS — Terminal Sign-In

How a till authenticates, and how it finds out which shop it belongs to.

Base URL https://fiesta.nearle.app/live/api/v1/pos Live since 6 Aug 2026, v1.3.98


What changed, and why it matters

A terminal used to hold a store id typed into Settings and a password compiled into the app. That made the store id a claim rather than a fact: any till could name any outlet and be believed, so changing one number on one screen moved a terminal into another tenant's books. The password was identical on every install of a build.

Now a person signs in with their own back-office account, and the outlet arrives as a consequence — sealed inside a signed token the terminal cannot edit, and re-checked by the server on every request.

The rule to hold onto: the till no longer decides which shop it is. It is told.


Quickstart

BASE=https://fiesta.nearle.app/live/api/v1/pos

# 1. Sign in — a mobile number and a 4-digit PIN
curl -s -X POST $BASE/login \
  -H 'Content-Type: application/json' \
  -d '{"contactno":"9876543210","pin":"4821","terminal_id":"T5EDD"}'

# 2. Use the token on everything else
curl -s $BASE/session -H "Authorization: Bearer $TOKEN"

The flow

These steps are in order, and the order matters.

1. Sign in. POST /login with the operator's mobile number and 4-digit PIN — the pair the back office issued them. Both are held on their own app_users row; there is no separate POS credential store.

2. Read store_id out of the response. Do not ask anyone to type it. It is whatever the back office says that account's outlet is.

3. If locations has more than one entry, ask which one. Only then. A single-outlet account gets a list of one and must never see a picker.

4. Save the token. Platform keystore, not a plain file or SQLite — it is a bearer credential for a whole trading day. Restore it on launch before any upload or catalogue pull runs.

5. Send it on every request as Authorization: Bearer <token>.

6. Import staff. Replace the till's local staff with what came down, and deactivate anything that wasn't in the list. That is what retires the built-in PINs.


POST /login

The only unauthenticated route. It is where a token comes from.

Request

{
  "contactno": "9876543210",
  "pin": "4821",
  "terminal_id": "T5EDD",
  "device_id": "a5f3…",
  "location_id": 1135,
  "configid": 1
}
Field Required Notes
contactno yes Mobile number. Send it as typed — +91 98765 43210, 098765-43210 and 9876543210 all reach the same account.
pin yes Exactly 4 digits, never starting with 0.
terminal_id no This till's short code, e.g. T5EDD. Recorded on the session.
device_id no The device's stable UUID.
location_id no Only meaningful for a multi-outlet account. A request, not an assertion — it is checked against what the account may reach.
configid no Inferred when absent. Send it only if you get the ambiguity error below.
authname + password no The previous way in. Still accepted, so a shop whose numbers have not been backfilled is not stranded — see POS_PHONE_PIN_LOGIN_HANDOVER.md.

pin wins if a password is sent as well; authname wins over contactno.

Response — 200

{
  "code": 200,
  "status": true,
  "message": "Login successful",
  "details": {
    "token": "eyJ1aWQiOjEy….K3p9",
    "expires_at": "2026-09-05T10:51:17Z",

    "user_id": 1229,
    "full_name": "Selvapuram",
    "email": "rsselvapuram@gmail.com",
    "role_id": 0,

    "tenant_id": 1087,
    "tenant_name": "Ragul Stores",

    "store_id": "1135",
    "location_id": 1135,
    "location_name": "Ragul stores Selvapuram",
    "gstin": "123456",
    "address": "…",
    "phone": "…",

    "locations": [
      { "location_id": 1135, "location_name": "Ragul stores Selvapuram",
        "address": "", "city": "", "status": "Active" }
    ],

    "staff": [
      { "user_id": 1148, "full_name": "Ragul Kannan",
        "role": "Super admin", "status": "Active" }
    ]
  }
}

The fields that matter

store_id — a string, because that is the shape every uplink already sends. Use it verbatim as the store_id on /orders, /customers and /catalogue. It is the same value as location_id, handed back in the form it will be replayed in.

token — opaque. Do not parse it, do not read anything out of it, do not trust anything it appears to say. Its only correct use is to hand it back.

expires_at — 30 days out. Long on purpose: a shop signs a terminal in once and expects it to keep working. Forcing a re-login mid-shift means a queue of customers waiting while somebody finds the manager.

gstin / address / phone — print these on the receipt. They are a legal requirement on a GST invoice and they used to be compile-time constants, so a shop correcting its GSTIN had to wait for a rebuild. Write them locally on sign-in.

locations — every outlet this account may open a till at. Length 1 is the normal case.

staff — see Staff and PINs. Often empty, and it no longer carries pin: a PIN is now half of the sign-in, so a list of them is a list of working credentials for the outlet. Switch operator through POST /login/pin instead.


GET /session

Answers who the caller is, per their token. What a till calls on launch to check whether yesterday's session is still good, without making a real request and interpreting the failure.

Requires the token. Returns 401 when there isn't one.

{
  "code": 200,
  "status": true,
  "details": {
    "user_id": 1229,
    "tenant_id": 1087,
    "location_id": 1135,
    "store_id": "1135",
    "role_id": 0,
    "terminal_id": "PROBE",
    "expires_at": "2026-09-05T10:51:17Z"
  }
}

GET /staff

Who may ring a bill at this terminal's outlet. For pulling down somebody hired mid-shift without signing the terminal out.

Takes no parameters. The outlet comes from the caller's own token — a till must not be able to ask who works at the shop next door. A request without a token is refused whatever the enforcement setting is.

Names and roles only; pin is not returned here either, for the same reason it left the login session. A supervisor who needs to see or change one uses GET /pos/users, which is role-gated.

{
  "code": 200,
  "status": true,
  "details": {
    "location_id": 1135,
    "staff": []
  }
}

Roles

Two POS roles, added to app_roles:

roleid Role Can
7 Supervisor everything a till does, plus creating and editing counter staff
8 Cashier billing only

The session carries both, so the terminal never has to map role ids itself:

{ "role_id": 7, "role": "Supervisor", "can_manage_staff": true }

Branch on can_manage_staff, not on the number. app_roles holds six rows for four back-office roles (Admin is both 3 and 5, Manager both 4 and 6) and most accounts carry an id that is not in the table at all — any mapping written on the terminal would be wrong.

The till and Nearle Daily do not share accounts

app_users is the only thing the two products have in common. An account belongs to one or the other, never to both:

Nearle Daily app + console POS terminal
roles 1–6 — Super admin, Operations, Admin, Manager 7 Supervisor, 8 Cashier
/applogin, /tenant/weblogin, /tenant/login yes not found
POST /v1/pos/login 403 yes
listed by /getallusers, /getstaffs yes hidden

A Nearle Daily Super admin is not the administrator of anybody's POS. The back office reaches a till by provisioning a Supervisor from the console; it never becomes one by signing in.

This was the other way round until it was measured. Roles 1–6 counted as supervisors, on the reasoning that somebody who already administers a shop from a browser is not made less privileged by standing at the counter. That handed till-supervisor powers to 68 live accounts, 59 of them platform Super admins, while the actual shop accounts carry roleid 0 and were refused.

Both directions are now closed in the queries themselves rather than in a check each call site has to remember — a till account is not rejected by the app login, it is simply not found.

role_id 0 is not a role. It is what an account carries when nobody set one, 22 live accounts have it including a delivery rider, and it grants nothing on either side.

Every till account gets its own number and PIN

Both roles. A PIN now opens a closed terminal too, paired with the account's mobile number — which is what removed the old deadlock: /pos/login/pin needs a session that already exists, so before this a PIN-only account could not unlock a till at all. For a Supervisor that was an outright deadlock; for a Cashier it meant a shop that could not open until two people had arrived, and whoever gets in at seven is as often the cashier as the supervisor.

So a till account needs both contactno and pin set. One without the other cannot sign in. A username and password still work, and every account created before this still has them.

So a Cashier signs in exactly like a Supervisor does, and the role decides what they get — not which credential they used:

POST /v1/pos/login   supervisor.1185@pos.nearle.in   -> full shell
POST /v1/pos/login   cashier.1185@pos.nearle.in      -> billing only

POST /pos/users generates both when the request omits them, and returns the password once, in the creation response only:

{ "user_id": 1452, "role": "Cashier",
  "authname": "cashier.1185@pos.nearle.in",
  "password": "9tWx2KUJksM5Rm", "pin": "4513", "has_password": true }

GET /pos/users never returns a password, only has_password. An admin who loses it reissues rather than looks it up.

Send authname and password explicitly if the shop wants its people signing in as themselves. A generated name that collides — a second cashier at one outlet — becomes cashier2.1185@pos.nearle.in; a name you supplied is never adjusted, it is refused, because silently signing somebody in as another person's address is worse than an error.

The PIN is no longer optional in practice. The field still is — creation accepts an account without one — but an account with no PIN cannot sign a terminal in, and is told so by name: "this account has no PIN set; ask your supervisor to set one in the web console first."


POST /pos/login/pin — signing on at an open terminal

For a cashier taking over a counter a supervisor has already opened.

Requires an existing valid token. A PIN alone is four digits — ten thousand guesses, and no barrier to an anonymous caller. Tying it to a session confines the guesses to one outlet's staff, at a terminal somebody has already opened. That is why this route takes a bare PIN and /login does not: there, the PIN is checked against one mobile number, and the number is what makes the pair worth anything.

curl -s -X POST $BASE/login/pin \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"pin":"1602"}'

Returns a new session, with the same shape as /login. New rather than reused, because the token carries the role — a cashier taking over from a supervisor must drop their permissions, not inherit them.

401 if the PIN is not recognised. 400 if two people at the outlet share it, which creation refuses but older data may contain.


/pos/users — the shop's own counter staff

A supervisor creates their own cashiers, from the terminal.

The outlet is never in the request. Tenant and location come from the caller's token, so a supervisor at Selvapuram cannot create staff at R mart by sending a different number — the same inversion that stopped a till naming its own store id.

POST /pos/users

{
  "full_name": "Asha Kumar",
  "role": "cashier",
  "pin": "4821",
  "authname": "asha@shop.test",
  "password": "…"
}
Field Notes
full_name required; split across firstname/lastname
role "supervisor" or "cashier". Anything else is refused — never defaulted
pin optional, 4 digits. See the rules below
authname optional. Generated if omitted — cashier.1185@pos.nearle.in, or cashier2.… if that is taken
password optional. Generated if omitted, and returned once in this response

Everyone gets a username and a password, cashiers included, because a PIN cannot open a closed terminal. Omit both fields and they are generated for you, so provisioning a shop is one call per person.

The response is the only time the password is returned; GET /pos/users reports has_password and nothing more.

⚠️ PIN rules, and why

  • Exactly 4 digits, and cannot start with 0. app_users.pin is a bigint, so "0451" would be stored as 451 and read back as three digits — a cashier would type four and be refused for ever. One such account already exists in live data.
  • 1234, 1111, 2345, 4321, 9999, 2222, 3456, 0000 are refused. Live data has 1234 on eleven accounts and 1111 on nine.
  • Unique within the outlet, not globally. A PIN only distinguishes people at one counter; making it platform-unique would exhaust the space fast.

Answers 201 with the created user. Every failure is a 400 carrying the reason, because all of them are things the caller can fix.

GET /pos/users

Readable by anyone signed in — the terminal needs it to show who is on shift. A cashier gets the list with pin blanked; only somebody who could set a PIN gets to see one. ?include_inactive=true to see leavers.

PUT /pos/users

Same fields plus user_id. Send only what changes. Supervisor only.

DELETE /pos/users?user_id=9189

Deactivates — never deletes, because bills carry the cashier's name and shifts settle against it. Supervisor only, and you cannot deactivate the account you are signed in as: otherwise the last supervisor at a shop can lock everyone out with one tap.


Creating staff from the web console

The same staff management, for the screen an admin actually uses. Registered under both /v1/web/tenants and /v1/mob/tenants.

GET    /v1/web/tenants/posroles
GET    /v1/web/tenants/getposusers?tenantid=1087&locationid=1135
POST   /v1/web/tenants/createposuser
PUT    /v1/web/tenants/updateposuser
DELETE /v1/web/tenants/deleteposuser?tenantid=1087&locationid=1135&userid=9189

createposuser takes the same body as /pos/users, plus the outlet — the console has no session token, so it has to name one:

{
  "tenantid": 1087,
  "locationid": 1135,
  "full_name": "Asha Kumar",
  "role": "cashier",
  "pin": "4821"
}

These run the same service calls as /pos/users. A supervisor created from a browser is the same row, with the same rules applied, as one created at a counter — same PIN validation, same duplicate check, same identity-column allocation. That is the point of them: two paths writing one table is how the two stop matching.

configid is never asked for. It is inferred from whichever value the tenant's existing accounts carry — a number nobody looks up, that varies per tenant (1087 is spread across 1, 6 and 15), and that silently creates an account nobody can find if it is wrong.

GET /posroles returns the two roles with their ids and labels, so a console offering the choice never has to know that supervisor is 7.

🔴 These are unauthenticated

Like every other route in the /v1/web and /v1/mob groups — there is no auth middleware anywhere on the web API. The outlet is checked against the tenant before anything is written, so a caller cannot create staff at a shop that is not theirs given a tenant id — but nothing proves the caller is that tenant.

So this mints till credentials on an unauthenticated request. It is consistent with the rest of the platform, and it is still the weakest point in this design. They should move behind a session guard as soon as the console can hold one.

The terminal routes are not affected: /pos/users proves its outlet with a signed token.


Using the token

Authorization: Bearer eyJ1aWQiOjEy….K3p9

X-Pos-Token: <token> is accepted as a fallback, because some shop routers strip Authorization headers over plain HTTP. A bare token with no Bearer prefix is tolerated too.

Send it on every POS call: /orders, /customers, /catalogue, /health, /sales*, /session, /staff.

What the server checks

  1. The token verifies against our signing key and has not expired.
  2. The outlet named in the request belongs to the token's tenant.

The second is the one that matters. A valid token is a licence to name your outlets, not any outlet. The outlet is read from the query string and from the JSON body, because /orders and /customers carry store_id in the batch and never in the URL.

GET /catalogue?store_id=1135  →  200   your outlet
GET /catalogue?store_id=1185  →  403   {"message":"this session cannot reach outlet 1185"}

Errors

Sign-in

Code Meaning What the till should do
400 Body unreadable, or a mobile number is required / a PIN is required — one of the two fields is empty Fix the request; do not send it again unchanged
401 those sign-in details were not recognised Ask them to re-type. A wrong number and a wrong PIN give the same message — deliberately, so the endpoint isn't a directory of who banks here. A number that cannot be ten digits, and a PIN that is not four, answer the same way
403 Real account, but it can't open this till Show the message; re-typing won't help

The 403 messages, verbatim:

  • this account is not set up for the till; ask your store admin to add you as a Supervisor or Cashier in the web console
  • this account is inactive; contact your administrator
  • this account has no PIN set; ask your supervisor to set one in the web console first
  • this account has no password set; set one in the web console first
  • this account is not attached to a tenant and cannot open a till
  • no active outlet is registered for this account
  • this account cannot open a till at outlet 1185
  • more than one account uses these sign-in details; ask your administrator for the configid and send it with the login

That last one is real, not theoretical: neither authname nor contactno is unique in this schema. Live data has the same address twice, and 34 mobile numbers shared by 104 accounts. We refuse rather than pick one, because picking wrong means billing into another tenant's books.

Sign-in only ever looks at till accounts (roleid 7 and 8), which is what makes a number workable as a credential: it has to be unique among a tenant's own till staff, not across all 608 users on the platform.

The first one is the common case now, and it is deliberately specific where a bad password is deliberately vague. By the time it fires the caller has already proved the credential, so naming the reason leaks nothing they did not just demonstrate — and the vague answer would send a shop owner hunting for a password that was never wrong.

Authenticated routes

Code Meaning
401 No token, malformed token, bad signature, or expired — sign in again
403 Valid token naming an outlet the tenant doesn't own

Multi-outlet accounts

An account pinned to one location gets that location. An account with no location — a proprietor with several shops — gets all of the tenant's active outlets.

rsselvapuram@gmail.com  →  1 outlet   (1135, Selvapuram)
raguladmin@gmail.com    →  6 outlets  (1097, 1135, 1137, 1138, 1139, 885536644)

When locations.length > 1:

  1. Show a picker. Don't make it dismissable — a terminal has to be standing somewhere, and silently defaulting to the first outlet is how a day's takings get filed against the wrong shop.
  2. Sign in again with location_id set to their choice.

Re-signing-in is not laziness. The outlet is inside the signed token, so only the server can issue one for a different shop — and re-checking entitlement at that moment is the point.


Staff and PINs

Two different credentials, easily confused:

Says Checked by
Sign-in (email + password) which shop this terminal is the server
PIN which person rang this bill the terminal, offline

The PIN stamps cashiername and is what shifts settle against. It is shift attribution, not a security boundary — the boundary is the token.

The PIN comes down in the clear

Over TLS, and that's considered rather than sloppy. Four digits are brute-forceable in microseconds whatever they're wrapped in, so hashing server-side would buy the appearance of strength and not the substance — while costing something real, because the terminal salts every PIN with its own random salt before storing it and could never verify a hash computed on the server.

Store it hashed on the device. It arrives in the clear; it must not sit that way.

Importing

Write everyone in staff, keyed on user_id so a re-sync updates rather than duplicates. Then deactivate everything you didn't just import — that is what kills the built-in PINs. Deactivate, never delete: bills carry the cashier's name.

⚠️ staff is usually empty today

Only 116 of 596 accounts on the platform have a PIN set. Outlet 1135 — the one the terminal ships pointed at — has zero.

So:

  • An empty list is not a failure. Do nothing and leave the till exactly as it was.
  • A list where every PIN is unusable (0, blank) must behave the same way. Deactivating the local accounts because the back office isn't filled in yet would leave a counter nobody can sign in to.

The terminal still ships with three seeded logins for exactly this reason. They retire automatically the moment real staff exist. Filling in real PINs in the back office is what makes that happen.


Current state

Endpoints live on v1.3.98, all three pods
Signing key set in app-secrets
Enforcement OFF — POS_AUTH_REQUIRED is unset

Enforcement being off means a request carrying no token is still allowed through, so terminals already trading don't stop the day this ships. It does not mean tokens are ignored:

  • a token that's present and invalid is always refused;
  • a valid token naming another tenant's outlet is always refused.

Once the fleet is on a build that signs in, POS_AUTH_REQUIRED=true closes the door on untokened requests.


Known limitations

  • Passwords are stored in plaintext across the whole platform, not just here. Fixing it is a migration touching every login path.
  • No role check. Any active account with a tenant, a password and an active outlet can open a till — including roleid 0, which isn't in app_roles at all and currently includes a delivery rider. The damage is bounded by the token: they can only reach their own tenant's books.
  • 1135 means two different things. It's a location (Ragul stores Selvapuram, under tenant 1087) and separately a tenant (Suriya Store). Same number, different tables. Watch for it in logs.