Files
backend_fiesta/POS_LOGIN.md
Suriya 4b27b84b1f Let a shop run its own counter: supervisor and cashier, created from the till
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>
2026-08-06 20:22:16 +05:30

494 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```bash
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
```json
{
"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`
```json
{
"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.
**`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](#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.
```json
{
"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.
```json
{
"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:
```json
{ "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.
Back-office roles 16 also count as supervisors: somebody who already
administers the shop from a browser is not made less privileged by standing at
the counter. **`role_id` 0 is not a role** — it is what an account carries when
nobody set one, and it grants nothing.
---
## `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.** That is the security model, not an
oversight: four digits is ten thousand guesses, which is no barrier at all to an
anonymous caller. Tying it to a session means a supervisor has opened the
terminal with a real password first, and the guesses are confined to that one
outlet's staff.
```bash
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`
```json
{
"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` | 4 digits. See the rules below |
| `password` + `authname` | optional; for someone who also signs the terminal in |
**At least one of `pin` or `password` is required.** Creating a person who can
sign in by neither would look like it worked right up until somebody tried.
:warning: **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.
---
## 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.
### :warning: `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.