An admin sets a shop up from a browser; a supervisor adds a cashier at the counter. Both had to be possible, and only the second one was. So the console gets createposuser / updateposuser / getposusers / deleteposuser, under both /v1/web/tenants and /v1/mob/tenants — calling the same service methods `/pos/users` calls. Not a parallel implementation: a supervisor created from a browser is the same row, with the same PIN rules, the same duplicate check and the same identity-column allocation, as one created at a till. Two paths writing one table is precisely how the two stop matching, and this codebase already had that happen once. configid is inferred rather than asked for. It is a number nobody looks up, it varies per tenant — 1087's accounts are spread across 1, 6 and 15 — and getting it wrong creates somebody who cannot sign into the portal their colleagues use and is invisible to half the platform's queries. /posroles is served rather than left to the console to hardcode. A console that knew supervisor was 7 would be wrong the day that changed and would have no way to find out. The outlet is the real difference between the two doors. A terminal proves it with a signed token; the console asserts it, and is checked against the tenant before anything is written. That is weaker, and it is worth being plain about: these mint till credentials on an unauthenticated request, exactly like every other route in the /v1/web and /v1/mob groups, because there is no auth middleware on the web API at all. Documented as the weakest point in the design and flagged to move behind a session guard once the console can hold one. The terminal routes are untouched by it. Proven in a rolled-back transaction against live data: the console creates a supervisor at 1135, that supervisor signs in by PIN with can_manage_staff true, the till's /pos/staff sees them alongside the two created at the counter, and 0451, 1234 and a duplicate PIN are each refused with the same message the terminal gives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
550 lines
18 KiB
Markdown
550 lines
18 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": []
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 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 1–6 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.
|
||
|
||
---
|
||
|
||
## 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:
|
||
|
||
```json
|
||
{
|
||
"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`.
|
||
|
||
### :red_circle: 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 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.
|