diff --git a/POS_LOGIN.md b/POS_LOGIN.md new file mode 100644 index 0000000..47bc375 --- /dev/null +++ b/POS_LOGIN.md @@ -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 `. + +**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: ` 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. diff --git a/scratch/liveloginproof/main.go b/scratch/liveloginproof/main.go new file mode 100644 index 0000000..a1595b7 --- /dev/null +++ b/scratch/liveloginproof/main.go @@ -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 +}