Files
backend_fiesta/POS_LOGIN.md
Suriya c696ec3e79 Document terminal sign-in, and prove it against the deployed API
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>
2026-08-06 16:26:52 +05:30

12 KiB

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
curl -s -X POST $BASE/login \
  -H 'Content-Type: application/json' \
  -d '{"authname":"rsselvapuram@gmail.com","password":"…","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 own credentials — the same app_users account they use for the web console. There is no separate POS password.

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

{
  "authname": "rsselvapuram@gmail.com",
  "password": "…",
  "terminal_id": "T5EDD",
  "device_id": "a5f3…",
  "location_id": 1135,
  "configid": 1
}
Field Required Notes
authname yes* Email. Or send contactno instead.
contactno yes* Mobile number, as an alternative to authname.
password yes
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.

* one of authname or 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", "pin": "1111", "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.

tokenopaque. 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.


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 answer carries PINs, so 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.

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

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 neither authname nor contactno sent Fix the request
401 those sign-in details were not recognised Ask them to re-type. Wrong email and wrong password give the same message — deliberately, so the endpoint isn't a directory of who banks here
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 inactive; contact your administrator
  • 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: authname is not unique in this schema. Live data has the same address twice. We refuse rather than pick one, because picking wrong means billing into another tenant's books.

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 OFFPOS_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.