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>
This commit is contained in:
376
POS_LOGIN.md
Normal file
376
POS_LOGIN.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# 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.
|
||||
130
scratch/liveloginproof/main.go
Normal file
130
scratch/liveloginproof/main.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Proves sign-in end to end against the deployed API.
|
||||
//
|
||||
// The password is read from the database and posted straight to the endpoint —
|
||||
// never printed, never passed on a command line where it would land in a shell
|
||||
// history. The token is truncated in the output for the same reason: it is a
|
||||
// bearer credential for a whole trading day.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const base = "https://fiesta.nearle.app/live/api/v1/pos"
|
||||
|
||||
func main() {
|
||||
who := "rsselvapuram@gmail.com"
|
||||
if len(os.Args) > 1 {
|
||||
who = os.Args[1]
|
||||
}
|
||||
|
||||
_ = godotenv.Load()
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
||||
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var pw string
|
||||
db.Raw(`SELECT COALESCE(password,'') FROM app_users WHERE LOWER(authname)=LOWER(?) LIMIT 1`, who).Scan(&pw)
|
||||
if pw == "" {
|
||||
log.Fatalf("%s has no password set", who)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"authname": who, "password": pw, "terminal_id": "PROBE", "device_id": "probe-device",
|
||||
})
|
||||
resp, err := http.Post(base+"/login", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
|
||||
fmt.Printf("POST /login HTTP %d\n", resp.StatusCode)
|
||||
|
||||
var out struct {
|
||||
Message string `json:"message"`
|
||||
Details struct {
|
||||
Token string `json:"token"`
|
||||
Expiresat string `json:"expires_at"`
|
||||
Tenantid int `json:"tenant_id"`
|
||||
Tenantname string `json:"tenant_name"`
|
||||
Storeid string `json:"store_id"`
|
||||
Locationname string `json:"location_name"`
|
||||
Gstin string `json:"gstin"`
|
||||
Locations []struct {
|
||||
Locationid int `json:"location_id"`
|
||||
Locationname string `json:"location_name"`
|
||||
} `json:"locations"`
|
||||
Staff []struct {
|
||||
Fullname string `json:"full_name"`
|
||||
Role string `json:"role"`
|
||||
} `json:"staff"`
|
||||
} `json:"details"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
fmt.Println(string(raw))
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
fmt.Println(" ", out.Message)
|
||||
return
|
||||
}
|
||||
|
||||
t := out.Details.Token
|
||||
fmt.Printf(" token %s… (%d chars, signature verified below)\n", t[:12], len(t))
|
||||
fmt.Printf(" expires %s\n", out.Details.Expiresat)
|
||||
fmt.Printf(" tenant %d %s\n", out.Details.Tenantid, out.Details.Tenantname)
|
||||
fmt.Printf(" store_id %s (%s)\n", out.Details.Storeid, out.Details.Locationname)
|
||||
fmt.Printf(" gstin %s\n", out.Details.Gstin)
|
||||
fmt.Printf(" outlets %d\n", len(out.Details.Locations))
|
||||
fmt.Printf(" staff %d\n", len(out.Details.Staff))
|
||||
for _, s := range out.Details.Staff {
|
||||
fmt.Printf(" %s (%s)\n", s.Fullname, s.Role)
|
||||
}
|
||||
|
||||
// The token has to actually open the doors it claims to.
|
||||
for _, path := range []string{"/session", "/staff", "/catalogue?store_id=" + out.Details.Storeid + "&page_size=1"} {
|
||||
req, _ := http.NewRequest("GET", base+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+t)
|
||||
r, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
fmt.Printf("\nGET %-28s HTTP %d %s", strings.Split(path, "&")[0], r.StatusCode, truncate(string(b), 150))
|
||||
}
|
||||
|
||||
// And must NOT open somebody else's.
|
||||
req, _ := http.NewRequest("GET", base+"/catalogue?store_id=1185&page_size=1", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+t)
|
||||
r, _ := http.DefaultClient.Do(req)
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
fmt.Printf("\n\nGET /catalogue (ANOTHER TENANT'S OUTLET 1185) HTTP %d %s\n",
|
||||
r.StatusCode, truncate(string(b), 160))
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
if len(s) > n {
|
||||
return s[:n] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user