pos gap fix

This commit is contained in:
2026-09-30 18:01:59 +05:30
parent ea90b95cdc
commit 97f277f424
6 changed files with 609 additions and 0 deletions

View File

@@ -1015,3 +1015,20 @@ func (ctl *PosController) WebUpdateStaffShift(c *fiber.Ctx) error {
"message": "Shift updated", "details": shift,
})
}
// AuthAdoption reports how much of the till fleet is carrying a session token.
//
// The answer to "is it safe to set POS_AUTH_REQUIRED=true yet". Every outlet it
// lists is a till that would stop being able to ring a bill the moment
// enforcement goes on.
//
// Behind the web session guard on purpose: that list is also a map of which
// shops are reachable without a credential today.
func (ctl *PosController) AuthAdoption(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"code": http.StatusOK,
"status": true,
"message": "Success",
"details": middleware.PosAdoptionReport(),
})
}

12
main.go
View File

@@ -1,12 +1,14 @@
package main
import (
"context"
"fmt"
"log"
"nearle/config"
"nearle/db"
"nearle/facade"
"nearle/messaging"
"nearle/middleware"
"nearle/models"
"nearle/repositories"
"nearle/routes"
@@ -510,6 +512,16 @@ func main() {
repositories.SetCatalogueNotifier(posMqtt)
}
// How much of the till fleet is carrying a session token, in the log every
// half hour.
//
// `POS_AUTH_REQUIRED` is off, and the only thing between here and switching
// it on is that number — nothing was recording it, so an untokened till was
// waved through in silence and the risk of flipping the flag could only be
// measured by flipping it. The same figures are on
// `GET /v1/web/pos/authadoption`, behind the session guard.
go middleware.LogPosAdoption(context.Background())
// Start server on APP_PORT (1122 locally, 1009 in production — see the
// env files). Running a second copy beside something else is a one-line
// change there rather than here.

View File

@@ -55,6 +55,12 @@ func PosAuth(pos services.PosService) fiber.Handler {
token := bearerToken(c)
if token == "" {
// Counted before anything else happens to it. This is the number
// that decides when POS_AUTH_REQUIRED can be switched on, and
// nothing else in the system was recording it — the request was
// simply waved through in silence. See posauthadoption.go.
recordPosUntokened(requestedLocation(c), c.Path())
if posAuthRequired() {
return posUnauthorized(c, "a session token is required; sign in at /pos/login")
}
@@ -96,6 +102,12 @@ func PosAuth(pos services.PosService) fiber.Handler {
}
}
// A till that has adopted the new sign-in. Counted only once the token
// has verified AND the outlet check has passed, so the figure means
// "requests this guard would still serve with enforcement on" rather
// than "requests that carried something token-shaped".
recordPosToken()
c.Locals(PosLocalsKey, claims)
return c.Next()
}

View File

@@ -0,0 +1,249 @@
package middleware
import (
"context"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"
)
/*
How much of the till fleet is carrying a session token.
── Why this exists ─────────────────────────────────────────────────────────
`POS_AUTH_REQUIRED` is off, and the only thing standing between here and
switching it on is a number nobody has: how many terminals still call the POS
routes with no token. Flipping the flag blind is the one action on this surface
that can stop a shop trading mid-queue — a cashier at a counter unable to ring a
bill is not a reversible inconvenience.
So this counts, and names the outlets that are still untokened, so the flag gets
flipped on evidence rather than on hope.
── What it deliberately is not ─────────────────────────────────────────────
Not persisted. It lives in memory and resets on restart, which is honest about
what it measures: adoption since this process started, not all time. A restart
mid-observation means starting the week again, and that is a smaller cost than a
migration and a table for a number that stops mattering the day the flag is on.
Not a rate limiter and not a gate. It records; it never refuses. Nothing in
here can change whether a request is served.
Capped. `store_id` comes off the wire, so an untokened caller could otherwise
name ten thousand outlets and grow this without bound. Past the cap new outlets
are counted in the totals and not listed individually, which keeps the answer
useful without making it a way to spend the server's memory.
*/
// posAdoptionCap is how many distinct untokened outlets are named individually.
// The real fleet is dozens; anything beyond this is noise or somebody probing.
const posAdoptionCap = 200
type posOutletSeen struct {
Locationid int
Requests int64
FirstSeen time.Time
LastSeen time.Time
}
var posAdoption = struct {
sync.Mutex
since time.Time
tokened int64
untokened int64
// Untokened requests by the outlet they named, and by the route they hit.
outlets map[int]*posOutletSeen
paths map[string]int64
// True once the cap was reached, so the report can say it is partial
// rather than quietly under-reporting.
truncated bool
}{
since: time.Now(),
outlets: map[int]*posOutletSeen{},
paths: map[string]int64{},
}
// recordPosToken notes one request that arrived with a usable token.
func recordPosToken() {
posAdoption.Lock()
posAdoption.tokened++
posAdoption.Unlock()
}
// recordPosUntokened notes one request that arrived with none, and where it
// claimed to be. `locationid` is 0 when the route named no outlet.
func recordPosUntokened(locationid int, path string) {
now := time.Now()
posAdoption.Lock()
defer posAdoption.Unlock()
posAdoption.untokened++
posAdoption.paths[path]++
if locationid <= 0 {
return
}
if seen, ok := posAdoption.outlets[locationid]; ok {
seen.Requests++
seen.LastSeen = now
return
}
if len(posAdoption.outlets) >= posAdoptionCap {
posAdoption.truncated = true
return
}
posAdoption.outlets[locationid] = &posOutletSeen{
Locationid: locationid, Requests: 1, FirstSeen: now, LastSeen: now,
}
}
// PosAdoptionOutlet is one outlet still calling without a token.
type PosAdoptionOutlet struct {
Locationid int `json:"locationid"`
Requests int64 `json:"requests"`
FirstSeen string `json:"firstseen"`
LastSeen string `json:"lastseen"`
}
// PosAdoptionPath is one route, and how often it was reached untokened.
type PosAdoptionPath struct {
Path string `json:"path"`
Requests int64 `json:"requests"`
}
// PosAdoption is the answer to "is it safe to switch enforcement on yet".
type PosAdoption struct {
// Whether an untokened request is currently refused.
Enforced bool `json:"enforced"`
// When counting started — process start, not all time.
Since string `json:"since"`
// Requests seen on the POS surface since then.
Tokened int64 `json:"tokened"`
Untokened int64 `json:"untokened"`
// 0–100. 100 means every request in this window carried a token, which is
// the condition for flipping the flag.
AdoptedPercent float64 `json:"adoptedpercent"`
// The outlets still calling without one, busiest first. These are the tills
// that would stop working the moment enforcement is switched on.
Outlets []PosAdoptionOutlet `json:"outlets"`
// Which routes they are reaching, busiest first.
Paths []PosAdoptionPath `json:"paths"`
// True when more outlets were seen than are listed — see posAdoptionCap.
Truncated bool `json:"truncated"`
// Plain-language reading of the above, for whoever has to make the call.
Verdict string `json:"verdict"`
}
// PosAdoptionReport is the snapshot, safe to call at any time.
func PosAdoptionReport() PosAdoption {
posAdoption.Lock()
defer posAdoption.Unlock()
report := PosAdoption{
Enforced: posAuthRequired(),
Since: posAdoption.since.Format(time.RFC3339),
Tokened: posAdoption.tokened,
Untokened: posAdoption.untokened,
Truncated: posAdoption.truncated,
Outlets: make([]PosAdoptionOutlet, 0, len(posAdoption.outlets)),
Paths: make([]PosAdoptionPath, 0, len(posAdoption.paths)),
}
total := posAdoption.tokened + posAdoption.untokened
if total > 0 {
report.AdoptedPercent = float64(posAdoption.tokened) * 100 / float64(total)
}
for _, seen := range posAdoption.outlets {
report.Outlets = append(report.Outlets, PosAdoptionOutlet{
Locationid: seen.Locationid,
Requests: seen.Requests,
FirstSeen: seen.FirstSeen.Format(time.RFC3339),
LastSeen: seen.LastSeen.Format(time.RFC3339),
})
}
// Busiest first: the outlet ringing the most bills is the one that hurts
// most if enforcement switches on before it has adopted.
sort.Slice(report.Outlets, func(i, j int) bool {
return report.Outlets[i].Requests > report.Outlets[j].Requests
})
for path, count := range posAdoption.paths {
report.Paths = append(report.Paths, PosAdoptionPath{Path: path, Requests: count})
}
sort.Slice(report.Paths, func(i, j int) bool {
return report.Paths[i].Requests > report.Paths[j].Requests
})
report.Verdict = posAdoptionVerdict(report)
return report
}
// posAdoptionVerdict says what the numbers mean, because the number on its own
// invites the wrong reading in both directions: a clean window that is only an
// hour long proves nothing, and one stubborn outlet is not a reason to leave
// the whole surface open.
func posAdoptionVerdict(r PosAdoption) string {
switch {
case r.Enforced:
return "Enforcement is already on: an untokened request is refused."
case r.Tokened+r.Untokened == 0:
return "No POS traffic seen since this process started, so there is nothing to conclude yet."
case r.Untokened == 0:
return "Every POS request in this window carried a token. Watch for a few trading days — a quiet window is not the same as an adopted fleet — then set POS_AUTH_REQUIRED=true."
case len(r.Outlets) == 0:
return "Untokened requests are arriving but none names an outlet, so they cannot be traced to a till. Check the paths below before switching enforcement on."
default:
return "Terminals are still calling without a token. The outlets listed below would stop being able to trade the moment POS_AUTH_REQUIRED=true is set. Update those tills first."
}
}
// posAdoptionLogEvery is how often the summary reaches the log.
//
// Long, because this is a slow-moving fact — a fleet adopts over days, not
// minutes — and a log line nobody needs every minute is a log line people learn
// to scroll past.
const posAdoptionLogEvery = 30 * time.Minute
// LogPosAdoption prints the summary on a timer until ctx is done.
//
// In the log as well as on the endpoint because the two get used by different
// people at different moments: somebody already reading Dokploy's log because a
// till is misbehaving should not have to know an endpoint exists.
//
// Outlet ids only, never names or counts of takings — a log is the one place
// this data ends up somewhere nobody chose to put it.
func LogPosAdoption(ctx context.Context) {
ticker := time.NewTicker(posAdoptionLogEvery)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
report := PosAdoptionReport()
if report.Tokened+report.Untokened == 0 {
continue // nothing happened; saying so every half hour is noise
}
if report.Untokened == 0 {
log.Printf("pos auth: %.0f%% of %d requests carried a token; no untokened terminals seen since %s",
report.AdoptedPercent, report.Tokened+report.Untokened, report.Since)
continue
}
outlets := make([]string, 0, len(report.Outlets))
for _, o := range report.Outlets {
outlets = append(outlets, strconv.Itoa(o.Locationid))
}
log.Printf("pos auth: %.0f%% of %d requests carried a token; %d untokened, from outlet(s) %s — these would stop trading if POS_AUTH_REQUIRED were set",
report.AdoptedPercent, report.Tokened+report.Untokened,
report.Untokened, strings.Join(outlets, ", "))
}
}
}

View File

@@ -0,0 +1,297 @@
package middleware
import (
"net/http/httptest"
"strings"
"testing"
"time"
"nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
/*
Counting the till fleet's adoption of the session token.
This exists to answer one question — is it safe to set POS_AUTH_REQUIRED=true —
and the cost of answering it wrong is a cashier at a counter who cannot ring a
bill. So these are mostly about the figure being honest: not flattering, not
alarmist, and never able to change whether a request is served.
*/
// resetAdoption puts the counters back, since they are process-wide.
func resetAdoption(t *testing.T) {
t.Helper()
posAdoption.Lock()
posAdoption.since = time.Now()
posAdoption.tokened = 0
posAdoption.untokened = 0
posAdoption.outlets = map[int]*posOutletSeen{}
posAdoption.paths = map[string]int64{}
posAdoption.truncated = false
posAdoption.Unlock()
}
func TestAnUntokenedTillIsNamedByItsOutlet(t *testing.T) {
// The whole point. Without this list, switching enforcement on is a guess
// about which shops stop trading.
resetAdoption(t)
recordPosUntokened(1185, "/live/api/v1/pos/orders")
recordPosUntokened(1185, "/live/api/v1/pos/orders")
recordPosUntokened(1170, "/live/api/v1/pos/catalogue")
recordPosToken()
report := PosAdoptionReport()
if report.Untokened != 3 || report.Tokened != 1 {
t.Fatalf("counts wrong: %+v", report)
}
if len(report.Outlets) != 2 {
t.Fatalf("outlets: %+v", report.Outlets)
}
// Busiest first — the outlet ringing the most bills is the one that hurts
// most if enforcement goes on before it has adopted.
if report.Outlets[0].Locationid != 1185 || report.Outlets[0].Requests != 2 {
t.Errorf("not ordered by traffic: %+v", report.Outlets)
}
}
func TestTheAdoptedPercentageIsOfEverything(t *testing.T) {
resetAdoption(t)
for i := 0; i < 3; i++ {
recordPosToken()
}
recordPosUntokened(1185, "/pos/orders")
if got := PosAdoptionReport().AdoptedPercent; got != 75 {
t.Fatalf("adopted = %v%%, want 75", got)
}
}
func TestNoTrafficIsNotAHundredPercent(t *testing.T) {
// A fleet nobody has used is not a fleet that has adopted. Reporting 100%
// here is the single most dangerous rounding this file could do — it would
// green-light the flag on an empty window.
resetAdoption(t)
report := PosAdoptionReport()
if report.AdoptedPercent != 0 {
t.Fatalf("empty window reported as %v%%", report.AdoptedPercent)
}
if !strings.Contains(report.Verdict, "nothing to conclude") {
t.Errorf("verdict oversells an empty window: %q", report.Verdict)
}
}
func TestACleanWindowStillSaysToKeepWatching(t *testing.T) {
// Zero untokened requests in one hour is not an adopted fleet — a shop that
// is shut has no traffic either. The verdict has to say so, because the
// number on its own reads as permission.
resetAdoption(t)
recordPosToken()
verdict := PosAdoptionReport().Verdict
if !strings.Contains(verdict, "trading days") {
t.Errorf("a one-request window was treated as proof: %q", verdict)
}
}
func TestAnUntokenedFleetIsNotDescribedAsReady(t *testing.T) {
resetAdoption(t)
recordPosToken()
recordPosUntokened(1185, "/pos/orders")
verdict := PosAdoptionReport().Verdict
if !strings.Contains(verdict, "stop being able to trade") {
t.Errorf("the consequence is not stated: %q", verdict)
}
}
func TestARequestThatNamesNoOutletIsStillCounted(t *testing.T) {
// `/pos/staff` deliberately takes no location parameter. Such a request is
// still an untokened till, and dropping it would understate the problem.
resetAdoption(t)
recordPosUntokened(0, "/live/api/v1/pos/staff")
report := PosAdoptionReport()
if report.Untokened != 1 {
t.Fatalf("not counted: %+v", report)
}
if len(report.Outlets) != 0 {
t.Errorf("invented an outlet for a request that named none: %+v", report.Outlets)
}
if len(report.Paths) != 1 || report.Paths[0].Path != "/live/api/v1/pos/staff" {
t.Errorf("the route was lost: %+v", report.Paths)
}
if !strings.Contains(report.Verdict, "cannot be traced") {
t.Errorf("verdict does not explain the blind spot: %q", report.Verdict)
}
}
func TestOutletsCannotGrowWithoutBound(t *testing.T) {
// `store_id` comes off the wire. Without a cap an untokened caller could
// name ten thousand outlets and spend the server's memory doing it.
resetAdoption(t)
for i := 1; i <= posAdoptionCap+50; i++ {
recordPosUntokened(i, "/pos/orders")
}
report := PosAdoptionReport()
if len(report.Outlets) > posAdoptionCap {
t.Fatalf("listed %d outlets, cap is %d", len(report.Outlets), posAdoptionCap)
}
if report.Untokened != int64(posAdoptionCap+50) {
// The total must stay true even when the list is trimmed.
t.Errorf("total under-reported: %d", report.Untokened)
}
if !report.Truncated {
t.Error("a trimmed list was presented as complete")
}
}
func TestFirstAndLastSeenAreBothKept(t *testing.T) {
// "This till stopped calling untokened three days ago" and "it did so a
// minute ago" are different facts, and only one of them means it has been
// updated. So the first sighting must stick and the last must move.
//
// The clock is wound back rather than slept through: the report formats to
// RFC3339, which is second-precision, and a test that waits a second to
// prove an assignment is a second every run forever.
resetAdoption(t)
recordPosUntokened(1185, "/pos/orders")
posAdoption.Lock()
seen := posAdoption.outlets[1185]
seen.FirstSeen = seen.FirstSeen.Add(-48 * time.Hour)
seen.LastSeen = seen.LastSeen.Add(-48 * time.Hour)
posAdoption.Unlock()
recordPosUntokened(1185, "/pos/orders")
out := PosAdoptionReport().Outlets[0]
if out.FirstSeen == "" || out.LastSeen == "" {
t.Fatalf("timestamps missing: %+v", out)
}
if out.Requests != 2 {
t.Errorf("requests = %d, want 2", out.Requests)
}
if out.FirstSeen == out.LastSeen {
t.Errorf("last seen never moved: %+v", out)
}
if out.FirstSeen > out.LastSeen {
// RFC3339 sorts lexically, so this comparison is meaningful.
t.Errorf("first seen is after last seen: %+v", out)
}
}
func TestTheReportSaysWhetherEnforcementIsAlreadyOn(t *testing.T) {
resetAdoption(t)
t.Setenv("POS_AUTH_REQUIRED", "true")
report := PosAdoptionReport()
if !report.Enforced {
t.Fatal("enforcement is on and the report says otherwise")
}
if !strings.Contains(report.Verdict, "already on") {
t.Errorf("verdict ignores that the work is done: %q", report.Verdict)
}
}
/* ── The guard still behaves exactly as it did ───────────────────────────── */
func TestCountingNeverChangesWhetherARequestIsServed(t *testing.T) {
// This whole file is instrumentation. If it can refuse a request, or let
// one through that should have been refused, it has become the thing it was
// built to make safe.
//
// Both sides of the flag, against the real middleware.
t.Setenv("POS_TOKEN_SECRET", posTestSecret)
for _, tc := range []struct {
name string
required string
want int
}{
{"off: an untokened till still trades", "", 200},
{"on: an untokened till is refused", "true", 401},
} {
t.Run(tc.name, func(t *testing.T) {
resetAdoption(t)
t.Setenv("POS_AUTH_REQUIRED", tc.required)
got := callPos(t, "GET", "/live/api/v1/pos/catalogue?store_id=1185", "")
if got != tc.want {
t.Fatalf("status %d, want %d", got, tc.want)
}
// Counted either way: the figure is about what the fleet is doing,
// not about what the flag currently allows.
if report := PosAdoptionReport(); report.Untokened != 1 {
t.Errorf("untokened = %d, want 1", report.Untokened)
}
})
}
}
func TestOnlyARequestThatWouldSurviveEnforcementCountsAsAdopted(t *testing.T) {
// A token that verifies but names somebody else's outlet is refused, and
// must NOT be counted as adopted — otherwise a misconfigured till inflates
// the very number used to decide the flag is safe to set.
t.Setenv("POS_TOKEN_SECRET", posTestSecret)
t.Setenv("POS_AUTH_REQUIRED", "")
resetAdoption(t)
token := posTokenFor(t, 1147, 1185)
if got := callPos(t, "GET", "/live/api/v1/pos/catalogue?store_id=9999", token); got != 403 {
t.Fatalf("a token was allowed to name another tenant's outlet: %d", got)
}
if report := PosAdoptionReport(); report.Tokened != 0 {
t.Errorf("a refused request was counted as adopted: %+v", report)
}
}
/* ── Harness ─────────────────────────────────────────────────────────────── */
const posTestSecret = "a-pos-signing-secret-of-ample-length"
// posLocations answers the tenant-owns-outlet question without a database.
// Only LocationAllowed is real; anything else the guard touched would panic,
// which is the signal wanted.
type posLocations struct {
services.PosService
}
func (posLocations) LocationAllowed(tenantID, locationID int) (bool, error) {
// Tenant 1147 owns 1185 and nothing else, which is all these tests need.
return tenantID == 1147 && locationID == 1185, nil
}
func posTokenFor(t *testing.T, tenantID, locationID int) string {
t.Helper()
token, _, err := utils.MintPosToken(utils.PosClaims{
Tenantid: tenantID, Locationid: locationID, Configid: 1,
}, time.Now())
if err != nil {
t.Fatalf("minting a terminal session: %v", err)
}
return token
}
func callPos(t *testing.T, method, target, token string) int {
t.Helper()
app := fiber.New()
app.Use("/live/api/v1/pos", PosAuth(posLocations{}))
app.All("/live/api/v1/pos/*", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
req := httptest.NewRequest(method, target, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("calling: %v", err)
}
return resp.StatusCode
}

View File

@@ -77,6 +77,28 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
registerPosStaffConsoleRoutes(api, f)
registerPosReadConsoleRoutes(api, f)
registerLiveRoutes(api, f)
registerPosAdoptionRoute(api, f)
}
// How much of the till fleet has adopted the session token.
//
// The number that decides when `POS_AUTH_REQUIRED` can be switched on. Nothing
// was recording it — an untokened request was waved through in silence — so the
// only way to judge the risk of flipping the flag was to flip it and watch.
//
// ── Why it is on /v1/web and only /v1/web ───────────────────────────────────
//
// It names the outlets still calling without a token, which is a list of the
// shops that would stop trading if enforcement went on today. That is exactly
// the list an attacker would want, so it sits behind `middleware.WebAuth` and
// NOT on the unauthenticated health endpoint, where the rest of "is this
// deployment wired up" lives.
//
// Registered on its own rather than inside registerPosReadConsoleRoutes,
// because that function deliberately mirrors every route onto `/v1/mob/pos`
// as well — which has no guard at all.
func registerPosAdoptionRoute(api fiber.Router, f *facade.Facade) {
api.Group("/v1/web/pos").Get("/authadoption", f.PosController.AuthAdoption)
}
// The same counter-sales reads, for callers that are not a terminal.