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:
Suriya
2026-08-06 16:26:52 +05:30
parent c4dfcd5387
commit c696ec3e79
2 changed files with 506 additions and 0 deletions

View 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
}