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>
377 lines
12 KiB
Markdown
377 lines
12 KiB
Markdown
# 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": []
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 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.
|