Compare commits
5 Commits
692c10e553
...
bb5f40926f
| Author | SHA1 | Date | |
|---|---|---|---|
| bb5f40926f | |||
| c516c224e5 | |||
| 771d6a51cf | |||
| 24339a8b51 | |||
| 01bc89ab77 |
@@ -1,16 +1,63 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/services"
|
"nearle/services"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// attachWebSession hands a signed-in console user their session token.
|
||||||
|
//
|
||||||
|
// Added to the login response rather than served from a second endpoint, so the
|
||||||
|
// console receives it on the call it already makes and nothing changes about
|
||||||
|
// when or how it signs in.
|
||||||
|
//
|
||||||
|
// The claims come from the user's own record, which is the whole point: until
|
||||||
|
// now the console asserted its tenant on every request and was believed, and
|
||||||
|
// sealing it under a signature here is what makes `middleware.WebAuth` able to
|
||||||
|
// refuse a request naming somebody else's.
|
||||||
|
//
|
||||||
|
// `Issuperadmin` is copied across as the ONLY source of cross-tenant access.
|
||||||
|
// Not the role — `app_roles` calls roleid 1 "Super admin" and tenant onboarding
|
||||||
|
// wrote 1 for every shop owner, so trusting the role would promote every
|
||||||
|
// merchant on the platform.
|
||||||
|
//
|
||||||
|
// A failure to mint is logged and swallowed, deliberately, while
|
||||||
|
// WEB_AUTH_REQUIRED is off: a deployment that has not set a signing key yet must
|
||||||
|
// still be able to sign in, or shipping this takes the console down everywhere
|
||||||
|
// the secret is missing. Once enforcement is on, no token means no session —
|
||||||
|
// which is then the correct and loud failure.
|
||||||
|
//
|
||||||
|
// The parameter is the underlying map type rather than `fiber.Map`, because the
|
||||||
|
// two login paths do not agree on which fiber that is: `AppLogin` returns the
|
||||||
|
// v1 package's `Map` and `TenantWebLogin` the v2 one. Both are
|
||||||
|
// `map[string]any`, so taking that accepts either without dragging the
|
||||||
|
// old import into this file.
|
||||||
|
func attachWebSession(resp map[string]any, info models.TenantUserInfo) {
|
||||||
|
token, expires, err := utils.MintWebToken(utils.WebClaims{
|
||||||
|
Userid: info.Userid,
|
||||||
|
Tenantid: info.Tenantid,
|
||||||
|
Locationid: info.Locationid,
|
||||||
|
Roleid: info.Roleid,
|
||||||
|
Configid: info.Configid,
|
||||||
|
Superadmin: info.Issuperadmin,
|
||||||
|
}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("login: could not issue a console session for user %d: %v", info.Userid, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp["token"] = token
|
||||||
|
resp["tokenexpiresat"] = expires.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
type UserController struct {
|
type UserController struct {
|
||||||
userService services.UserService
|
userService services.UserService
|
||||||
}
|
}
|
||||||
@@ -179,7 +226,7 @@ func (ctl *UserController) AppLogin(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
_, resp, err := ctl.userService.AppLogin(user)
|
info, resp, err := ctl.userService.AppLogin(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Use resp.Code if present, fallback to 409
|
// Use resp.Code if present, fallback to 409
|
||||||
code := http.StatusConflict
|
code := http.StatusConflict
|
||||||
@@ -189,6 +236,8 @@ func (ctl *UserController) AppLogin(c *fiber.Ctx) error {
|
|||||||
return c.Status(code).JSON(resp)
|
return c.Status(code).JSON(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attachWebSession(resp, info)
|
||||||
|
|
||||||
// ✅ Always return resp
|
// ✅ Always return resp
|
||||||
return c.Status(http.StatusOK).JSON(resp)
|
return c.Status(http.StatusOK).JSON(resp)
|
||||||
}
|
}
|
||||||
@@ -244,6 +293,7 @@ func (ctl *UserController) TenantWebLogin(c *fiber.Ctx) error {
|
|||||||
// Include tenant user info if login successful (code 200)
|
// Include tenant user info if login successful (code 200)
|
||||||
if code == fiber.StatusOK {
|
if code == fiber.StatusOK {
|
||||||
resp["details"] = info
|
resp["details"] = info
|
||||||
|
attachWebSession(resp, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(code).JSON(resp)
|
return c.Status(code).JSON(resp)
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ recommend. When the customer taps a store and a size, a second call confirms
|
|||||||
the shelf still has it — and if it does not, names the next-nearest store
|
the shelf still has it — and if it does not, names the next-nearest store
|
||||||
that does.
|
that does.
|
||||||
|
|
||||||
|
When the label fits several products — `"britannia"` names 258 of them — it
|
||||||
|
answers with a short "did you mean?" list instead of picking one, because a
|
||||||
|
confident price on the wrong biscuit is worse than one extra tap.
|
||||||
|
|
||||||
Base path: `/live/api/v1/mob/scan`. Every response uses the usual envelope
|
Base path: `/live/api/v1/mob/scan`. Every response uses the usual envelope
|
||||||
`{ code, status, message, details }`; the shapes below are `details`.
|
`{ code, status, message, details }`; the shapes below are `details`.
|
||||||
|
|
||||||
@@ -17,7 +21,13 @@ Base path: `/live/api/v1/mob/scan`. Every response uses the usual envelope
|
|||||||
photo ──Lens──▶ label
|
photo ──Lens──▶ label
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
POST /lookup ───▶ match + stores[] (recommended first)
|
POST /lookup ───▶ ambiguous:true + candidates[] "did you mean?"
|
||||||
|
│ │
|
||||||
|
│ customer taps one candidate
|
||||||
|
│ │
|
||||||
|
│ POST /lookup { brand, catalogueid }
|
||||||
|
│ │
|
||||||
|
└───▶ match + stores[] (recommended first) ◀──┘
|
||||||
│
|
│
|
||||||
customer taps a store + a size
|
customer taps a store + a size
|
||||||
│
|
│
|
||||||
@@ -26,11 +36,21 @@ photo ──Lens──▶ label
|
|||||||
ok:false + alternative → offer the other store
|
ok:false + alternative → offer the other store
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`/lookup` has two possible answers and the app must handle both.** A label
|
||||||
|
that names one product comes back with `match` + `stores`. A label that fits
|
||||||
|
several — a bare brand name like `"britannia"`, a generic word like
|
||||||
|
`"biscuits"` — comes back with `ambiguous: true` and `candidates`, and the
|
||||||
|
app asks the customer which one before any price is shown. Lens returns a
|
||||||
|
bare wordmark often, because it is usually the biggest thing printed on a
|
||||||
|
packet, so this is a normal path and not an error case.
|
||||||
|
|
||||||
`GET /stores` is for the "choose another shop" sheet: the customer's
|
`GET /stores` is for the "choose another shop" sheet: the customer's
|
||||||
registered stores, nearest first, independent of any product.
|
registered stores, nearest first, independent of any product.
|
||||||
|
|
||||||
## `POST /lookup`
|
## `POST /lookup`
|
||||||
|
|
||||||
|
Note the `//` notes below are annotations, not JSON — strip them.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"customerid": 5123,
|
"customerid": 5123,
|
||||||
@@ -39,16 +59,25 @@ registered stores, nearest first, independent of any product.
|
|||||||
"longitude": 77.0290,
|
"longitude": 77.0290,
|
||||||
"tenantids": [1135, 1140], // optional: what the app THINKS the customer joined
|
"tenantids": [1135, 1140], // optional: what the app THINKS the customer joined
|
||||||
"limit": 0 // optional: max stores, 0 = all
|
"limit": 0 // optional: max stores, 0 = all
|
||||||
|
|
||||||
|
// Instead of a label: name the product outright. This is how you resolve
|
||||||
|
// a candidate the customer tapped, and how a deep link or a "buy again"
|
||||||
|
// skips recognition. With both set, `label` is ignored.
|
||||||
|
// "brand": "britannia", "catalogueid": 7
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`label` is required **unless** `brand` and `catalogueid` are both given.
|
||||||
|
|
||||||
`tenantids` is verified, never trusted: the server intersects it with the
|
`tenantids` is verified, never trusted: the server intersects it with the
|
||||||
`tenantcustomers` table. Ids the customer is not actually registered with
|
`tenantcustomers` table. Ids the customer is not actually registered with
|
||||||
come back in `unregistered_tenantids` — treat that as "refresh the local
|
come back in `unregistered_tenantids` — treat that as "refresh the local
|
||||||
list". A list that matches nothing at all is treated as stale and all
|
list". A list that matches nothing at all is treated as stale and all
|
||||||
registered stores are used.
|
registered stores are used.
|
||||||
|
|
||||||
Response:
|
### Response A — one product identified
|
||||||
|
|
||||||
|
`ambiguous: false`, `match` set, `candidates` empty.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -59,6 +88,8 @@ Response:
|
|||||||
"image": "https://…", "score": 0.94, "method": "vector+text"
|
"image": "https://…", "score": 0.94, "method": "vector+text"
|
||||||
},
|
},
|
||||||
"catalogue_variants": [ { "…same shape…": "100 g" }, { "…": "200 g" } ],
|
"catalogue_variants": [ { "…same shape…": "100 g" }, { "…": "200 g" } ],
|
||||||
|
"ambiguous": false,
|
||||||
|
"candidates": [],
|
||||||
"confidence": 0.94,
|
"confidence": 0.94,
|
||||||
"available": true,
|
"available": true,
|
||||||
"recommended_locationid": 20,
|
"recommended_locationid": 20,
|
||||||
@@ -83,12 +114,57 @@ Response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
How to read it:
|
### Response B — several products fit, none clearly
|
||||||
|
|
||||||
- `match == null` → nothing recognised; show `message` and let them retry.
|
`ambiguous: true`, `match: null`, `stores: []`. Show a "did you mean?" list.
|
||||||
`confidence` below ~0.5 → recognised but unsure; confirm the name with the
|
|
||||||
customer before showing prices. `method: "text"` means no embedding model
|
```json
|
||||||
was involved (not configured, or it timed out) — be a little more cautious.
|
{
|
||||||
|
"label": "britannia",
|
||||||
|
"match": null,
|
||||||
|
"ambiguous": true,
|
||||||
|
"candidates": [
|
||||||
|
{ "brand": "britannia", "catalogueid": 23, "product_name": "Britannia Marie Gold",
|
||||||
|
"size": "250 g", "image": "https://…", "score": 0.95, "method": "text", "available": true },
|
||||||
|
{ "brand": "britannia", "catalogueid": 22, "product_name": "Britannia Good Day Butter Cookies",
|
||||||
|
"image": "https://…", "score": 0.95, "method": "text" },
|
||||||
|
{ "brand": "britannia", "catalogueid": 21, "product_name": "Britannia Good Day Cashew Cookies",
|
||||||
|
"image": "https://…", "score": 0.95, "method": "text" }
|
||||||
|
],
|
||||||
|
"confidence": 0.95,
|
||||||
|
"available": false,
|
||||||
|
"stores": [],
|
||||||
|
"catalogue_variants": [],
|
||||||
|
"message": "Which one is it? 1 of these 3 are in stock near you."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`confidence` is not low here, and that is not a bug.** "britannia" really
|
||||||
|
does appear in all three names, so relevance is high — what is missing is
|
||||||
|
*identification*. Gate on `ambiguous`, never on `confidence`: an app that
|
||||||
|
reads 0.95 as "sure enough to show a price" reintroduces the exact bug this
|
||||||
|
path exists to prevent.
|
||||||
|
- **`available` on a candidate** means at least one of the customer's
|
||||||
|
registered stores has it in stock right now. Candidates are ordered
|
||||||
|
available-first, so the list can show what is buyable before what is not
|
||||||
|
— and the field is absent (not `false`) when unavailable, so read it as
|
||||||
|
falsy, not as a required key.
|
||||||
|
- **To resolve a pick**, call `/lookup` again with that candidate's `brand`
|
||||||
|
and `catalogueid` and no label. You get Response A for that exact product,
|
||||||
|
with `method: "direct"` and `confidence: 1`.
|
||||||
|
- At most 10 candidates come back.
|
||||||
|
|
||||||
|
### How to read either response
|
||||||
|
|
||||||
|
- `match == null && !ambiguous` → nothing recognised; show `message` and let
|
||||||
|
them retry with a clearer photo.
|
||||||
|
- `ambiguous: true` → ask, do not guess. Never show a price on this path;
|
||||||
|
`stores` is deliberately empty.
|
||||||
|
- `confidence` below ~0.5 with a `match` → recognised but unsure; worth
|
||||||
|
confirming the name before showing prices. `method: "text"` means no
|
||||||
|
embedding model was involved (not configured, or it timed out) — be a
|
||||||
|
little more cautious. `method: "direct"` means the caller named the
|
||||||
|
product, so nothing was recognised at all.
|
||||||
- `stores` is ordered **in-stock first, then nearest**. Exactly one store has
|
- `stores` is ordered **in-stock first, then nearest**. Exactly one store has
|
||||||
`recommended: true` — the nearest with stock — and only when `available`
|
`recommended: true` — the nearest with stock — and only when `available`
|
||||||
is true. Stores that sell it but have nothing on the shelf are still listed
|
is true. Stores that sell it but have nothing on the shelf are still listed
|
||||||
@@ -205,6 +281,59 @@ Same `ScanStore` shape as inside `stores[]` above, without options.
|
|||||||
- **Identity** is the `customerid` in the body, like every other mobile
|
- **Identity** is the `customerid` in the body, like every other mobile
|
||||||
endpoint here — there is no auth layer yet (see `SECURITY_HANDOFF.md`).
|
endpoint here — there is no auth layer yet (see `SECURITY_HANDOFF.md`).
|
||||||
|
|
||||||
|
## Two decisions, and why
|
||||||
|
|
||||||
|
Both come from a proposal (2026-09-23) to have the app send vectors it
|
||||||
|
computed on the phone. Recorded here because the next person will ask.
|
||||||
|
|
||||||
|
### The app does not send `textvector`
|
||||||
|
|
||||||
|
An on-device MiniLM vector is only comparable to the catalogue's if the app
|
||||||
|
ships the identical model *and* tokenizer *and* pooling *and* normalisation;
|
||||||
|
a quantised tflite build usually drifts, and the failure is silent — the
|
||||||
|
ranking just gets worse. There is also nothing to gain: the server-side
|
||||||
|
embed is ~30 ms warm and the result is cached in Redis by label, so one
|
||||||
|
model call serves every customer who scans that product. A client-supplied
|
||||||
|
vector *defeats* that cache (the key would have to be the vector, not the
|
||||||
|
label), and 384 floats is ~5 KB of upload against ~12 bytes for
|
||||||
|
`"Milk Bikis"`. If the field ever arrives it can be accepted and validated,
|
||||||
|
but the app should not be asked to compute it.
|
||||||
|
|
||||||
|
**Send the full OCR text instead** if you want to give the server more to
|
||||||
|
work with — ~100 bytes, no model coupling, strictly more information than a
|
||||||
|
single label.
|
||||||
|
|
||||||
|
### The app does not send `imagevector` — yet
|
||||||
|
|
||||||
|
The catalogue *does* carry image vectors: every `brand_*` table has
|
||||||
|
`img_vector vector(1024)`, filled on 1885 of 2124 rows (empty in
|
||||||
|
`brand_haldirams`, `brand_kaleesuwari`, `brand_mdh`, `brand_zzsmoketest`).
|
||||||
|
That matches the proposed MobileNetV3-Small embedder, so the idea is
|
||||||
|
coherent and half-built — this flow simply does not read that column.
|
||||||
|
|
||||||
|
It stays unread for now because **Google Lens is already the image
|
||||||
|
recogniser, and a far better one**: photo → Lens → label is Google's product
|
||||||
|
recognition, trained on billions of images. Putting a 137M-parameter
|
||||||
|
ImageNet backbone searching 1885 vectors *behind* that adds little where
|
||||||
|
Lens succeeds, and MobileNetV3-Small — which struggles to tell one blue
|
||||||
|
biscuit wrapper from another — is unlikely to rescue the cases where Lens
|
||||||
|
fails. There is also an unverified dependency: the preprocessing the app
|
||||||
|
would use (BGR → centre crop → 224×224 INTER_AREA → RGB → `/255.0`) has to
|
||||||
|
match whatever the catalogue pipeline actually ran, or the search returns
|
||||||
|
confidently-ranked noise.
|
||||||
|
|
||||||
|
**What would change this:** the field data. Once live, count how often
|
||||||
|
`/lookup` returns `ambiguous: true` or nothing recognised. If Lens labels are
|
||||||
|
reliable, image search is polish; if that number is high, it becomes the
|
||||||
|
priority — and the first task is the cosine check (embed a known catalogue
|
||||||
|
product's image through the app's exact pipeline, compare with its stored
|
||||||
|
`img_vector`; ≈0.99 means the contract holds), not writing the query.
|
||||||
|
|
||||||
|
There is one non-recognition argument for it worth remembering: on-device
|
||||||
|
inference is free and needs no Google dependency, which matters if Cloud
|
||||||
|
Vision costs start to bite at volume. That is a business reason, not a
|
||||||
|
quality one.
|
||||||
|
|
||||||
## For backend developers
|
## For backend developers
|
||||||
|
|
||||||
### Where the code is
|
### Where the code is
|
||||||
@@ -219,7 +348,7 @@ Same `ScanStore` shape as inside `stores[]` above, without options.
|
|||||||
| `utils/embedding.go` | `Embedder` interface, OpenAI-compatible and Gemini clients |
|
| `utils/embedding.go` | `Embedder` interface, OpenAI-compatible and Gemini clients |
|
||||||
| `utils/geo.go` | coordinate parsing, haversine, opening hours, label tokenising |
|
| `utils/geo.go` | coordinate parsing, haversine, opening hours, label tokenising |
|
||||||
| `config/config.go` | `EmbeddingConfig` and its validation |
|
| `config/config.go` | `EmbeddingConfig` and its validation |
|
||||||
| `scratch/cataloguedims` | read-only check of the catalogue's embedding width / fill |
|
| `scratch/cataloguedims` | read-only check of every catalogue vector column's width and fill |
|
||||||
|
|
||||||
### Try it locally
|
### Try it locally
|
||||||
|
|
||||||
@@ -238,11 +367,13 @@ anything; a schema-only dump does not.
|
|||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
`go test ./services -run 'Lookup|Confirm|Stores|CatalogueFamily'` drives
|
`go test ./services -run 'Lookup|Confirm|Stores|Brand|Ambiguous|Specific|TextScore|Distinct|Naming'`
|
||||||
the whole pipeline through a fake repository (`services/scan_test.go`); no
|
drives the whole pipeline through a fake repository
|
||||||
database. `go test ./utils` covers both HTTP clients against `httptest`
|
(`services/scan_test.go`); no database. `go test ./utils` covers both HTTP
|
||||||
servers, and the geo helpers. Add a case to `scan_test.go`'s fixture when
|
clients against `httptest` servers, and the geo helpers. Add a case to
|
||||||
you change ranking — it is the spec.
|
`scan_test.go`'s fixture when you change ranking — it is the spec, and
|
||||||
|
`newBrandLabelFixture` in particular is the regression guard for the
|
||||||
|
brand-name bug described under Scoring.
|
||||||
|
|
||||||
### Knobs (constants in `scanService.go`)
|
### Knobs (constants in `scanService.go`)
|
||||||
|
|
||||||
@@ -251,6 +382,8 @@ you change ranking — it is the spec.
|
|||||||
| `scanLookupTimeout` | 5 s | whole lookup, including the model call |
|
| `scanLookupTimeout` | 5 s | whole lookup, including the model call |
|
||||||
| `scanCatalogueTopK` | 15 | rows taken from each brand table and from the merge |
|
| `scanCatalogueTopK` | 15 | rows taken from each brand table and from the merge |
|
||||||
| `scanMinScore` | 0.50 | below this the best hit is not shown as a match |
|
| `scanMinScore` | 0.50 | below this the best hit is not shown as a match |
|
||||||
|
| `scanAmbiguityMargin` | 0.06 | how close the runner-up may be before the answer becomes a question |
|
||||||
|
| `scanMaxCandidates` | 10 | longest "did you mean?" list |
|
||||||
| `embedTimeout` (`utils/embedding.go`) | 4 s | one model call |
|
| `embedTimeout` (`utils/embedding.go`) | 4 s | one model call |
|
||||||
| `scanVectorTTL` / `scanHitsTTL` (`scanRepository.go`) | 7 d / 30 min | cache lifetimes |
|
| `scanVectorTTL` / `scanHitsTTL` (`scanRepository.go`) | 7 d / 30 min | cache lifetimes |
|
||||||
|
|
||||||
@@ -274,6 +407,19 @@ Classic* at 0.9 (the "G" was dropped, so only "parle" matched either row),
|
|||||||
and the name tie-break handed it to Monaco because a space precedes a hyphen
|
and the name tie-break handed it to Monaco because a space precedes a hyphen
|
||||||
in ASCII. A confident, wrong answer — the kind no score floor can catch.
|
in ASCII. A confident, wrong answer — the kind no score floor can catch.
|
||||||
|
|
||||||
|
**When the substring rule ties, that tie is the answer.** A bare brand name
|
||||||
|
is a substring of every one of that brand's names, so all of them score 0.95
|
||||||
|
— identically, at a high score no floor would ever catch. Rather than
|
||||||
|
scoring around it, `isAmbiguous` reads it: if the runner-up is within
|
||||||
|
`scanAmbiguityMargin` of the leader, the reply becomes `ambiguous: true`
|
||||||
|
with `candidates` instead of a match (see Response B). Erring towards asking
|
||||||
|
is deliberate — one tap on a picture against the wrong biscuit. A label that
|
||||||
|
names one product leaves the runner-up far behind, so the common case is
|
||||||
|
untouched, and `services/scan_test.go`'s
|
||||||
|
`TestABrandNameScoresItsProductsIdentically` guards the tie itself: a
|
||||||
|
formula that broke it on name length or word count would bring the bug
|
||||||
|
back.
|
||||||
|
|
||||||
### Changing the embedding model
|
### Changing the embedding model
|
||||||
|
|
||||||
1. The catalogue team re-embeds `search_query` with the new model.
|
1. The catalogue team re-embeds `search_query` with the new model.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"nearle/controllers"
|
"nearle/controllers"
|
||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
"nearle/services"
|
"nearle/services"
|
||||||
|
"nearle/services/tools"
|
||||||
"nearle/utils"
|
"nearle/utils"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -25,6 +26,13 @@ type Facade struct {
|
|||||||
CatalogueUploadController *controllers.CatalogueUploadController
|
CatalogueUploadController *controllers.CatalogueUploadController
|
||||||
ScanController *controllers.ScanController
|
ScanController *controllers.ScanController
|
||||||
|
|
||||||
|
// Tools is what Nearle Buddy is allowed to do.
|
||||||
|
//
|
||||||
|
// Held on the facade because the assistant is not a module with a
|
||||||
|
// repository of its own — it is a door onto the services already built
|
||||||
|
// here, and every tool handler calls one of them rather than the database.
|
||||||
|
Tools *tools.Registry
|
||||||
|
|
||||||
// Held so the NATS consumer can reach the ingest without going through
|
// Held so the NATS consumer can reach the ingest without going through
|
||||||
// HTTP. Unexported: everything else should use the controller.
|
// HTTP. Unexported: everything else should use the controller.
|
||||||
posService services.PosService
|
posService services.PosService
|
||||||
@@ -119,6 +127,22 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder) *Faca
|
|||||||
scanService := services.NewScanService(scanRepo, embedder)
|
scanService := services.NewScanService(scanRepo, embedder)
|
||||||
scanController := controllers.NewScanController(scanService)
|
scanController := controllers.NewScanController(scanService)
|
||||||
|
|
||||||
|
// The assistant registry. Built last, because every tool it holds is a thin
|
||||||
|
// wrapper over a service constructed above.
|
||||||
|
//
|
||||||
|
// A registration error panics rather than being logged. A duplicate name or
|
||||||
|
// a tool with no description is a programming mistake, and a server that
|
||||||
|
// starts with a tool silently absent answers real questions with "I cannot
|
||||||
|
// do that" for a reason nobody can see from the outside.
|
||||||
|
toolRegistry := tools.New(tools.LogAudit{})
|
||||||
|
for _, tool := range []tools.Tool{
|
||||||
|
tools.StuckOrders(deliveriesService, nil),
|
||||||
|
} {
|
||||||
|
if err := toolRegistry.Register(tool); err != nil {
|
||||||
|
panic("assistant tools: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &Facade{
|
return &Facade{
|
||||||
UserController: userController,
|
UserController: userController,
|
||||||
ProductController: productController,
|
ProductController: productController,
|
||||||
@@ -134,6 +158,7 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder) *Faca
|
|||||||
LiveController: liveController,
|
LiveController: liveController,
|
||||||
CatalogueUploadController: catalogueUploadController,
|
CatalogueUploadController: catalogueUploadController,
|
||||||
ScanController: scanController,
|
ScanController: scanController,
|
||||||
|
Tools: toolRegistry,
|
||||||
posService: posService,
|
posService: posService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
312
middleware/webauth.go
Normal file
312
middleware/webauth.go
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/services"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authorisation for the console.
|
||||||
|
//
|
||||||
|
// The `/web` surface has never had any. The console keeps its login record in
|
||||||
|
// per-tab `sessionStorage` and sends no `Authorization` header, so every
|
||||||
|
// endpoint under `/v1/web` reads `tenantid` off the query string and believes
|
||||||
|
// it. Changing one number in a URL reads another merchant's orders, stock,
|
||||||
|
// staff and takings.
|
||||||
|
//
|
||||||
|
// This is the same hole `posauth.go` was written to close on the POS surface,
|
||||||
|
// and it is closed the same way, in the same order:
|
||||||
|
//
|
||||||
|
// 1. the caller holds a token this server signed, and
|
||||||
|
// 2. the tenant they are naming is the tenant inside that token.
|
||||||
|
//
|
||||||
|
// The second is the one that matters. A valid session is not a licence to name
|
||||||
|
// any tenant — it is a licence to name *your* tenant.
|
||||||
|
//
|
||||||
|
// ── Why this could not wait for the assistant ───────────────────────────────
|
||||||
|
//
|
||||||
|
// Nearle Buddy answers questions over this same data. Behind REST, reading
|
||||||
|
// another merchant's books takes knowing the endpoints, knowing the fields and
|
||||||
|
// iterating. Behind an assistant it is one sentence — "summarise the top ten
|
||||||
|
// tenants by revenue" — and the model assembles the cross-tenant answer itself,
|
||||||
|
// accurately and helpfully, because the data was in scope. The permission rules
|
||||||
|
// the assistant needs have nothing to stand on until this exists.
|
||||||
|
//
|
||||||
|
// ── What this does NOT yet do ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// It verifies what a request NAMES. It does not yet make handlers derive their
|
||||||
|
// scope from the session instead of from the wire, and it does not validate
|
||||||
|
// `partnerid`, `customerid` or `appuserid`, which are the other scoping ids
|
||||||
|
// some list endpoints accept. Those are the next step, and until they land a
|
||||||
|
// handler that scopes on one of them is still trusting the caller.
|
||||||
|
|
||||||
|
// WebLocalsKey names where the verified claims are parked for handlers.
|
||||||
|
const WebLocalsKey = "webclaims"
|
||||||
|
|
||||||
|
// webAuthRequired reports whether a request without a valid token is refused.
|
||||||
|
//
|
||||||
|
// Defaults to OFF, for the same reason POS enforcement does: the console is in
|
||||||
|
// use by real merchants right now, and its sign-in does not yet hand back a
|
||||||
|
// token. Switching enforcement on before the console sends one would lock every
|
||||||
|
// user out of a working product.
|
||||||
|
//
|
||||||
|
// So the order is: this middleware ships, sign-in starts issuing tokens, the
|
||||||
|
// console starts sending them, and `WEB_AUTH_REQUIRED=true` closes the door.
|
||||||
|
// While it is off a token is still VERIFIED when one is sent, and a request
|
||||||
|
// carrying a token for the wrong tenant is still refused — the flag only
|
||||||
|
// decides what happens to a request carrying none.
|
||||||
|
//
|
||||||
|
// This is a temporary state and should be short. An unauthenticated `/web`
|
||||||
|
// surface is the most serious thing in this codebase.
|
||||||
|
func webAuthRequired() bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(os.Getenv("WEB_AUTH_REQUIRED")), "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicWebPaths are the endpoints that must work before anybody has a token.
|
||||||
|
//
|
||||||
|
// Sign-in, chiefly: guarding the login route with a session token means nobody
|
||||||
|
// can ever obtain one. Kept as suffixes rather than full paths so the group
|
||||||
|
// prefix can move without silently locking the door.
|
||||||
|
var publicWebPaths = []string{
|
||||||
|
"/users/applogin",
|
||||||
|
"/users/weblogin",
|
||||||
|
"/tenant/weblogin",
|
||||||
|
// First-password-set runs before a session exists, from a link in the
|
||||||
|
// invitation mail.
|
||||||
|
"/users/setpassword",
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPublicWebPath(path string) bool {
|
||||||
|
lower := strings.ToLower(path)
|
||||||
|
for _, suffix := range publicWebPaths {
|
||||||
|
if strings.HasSuffix(lower, suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// webLocationChecker is the only question this middleware asks of the database:
|
||||||
|
// does this tenant own this branch? Narrowed to one method so the guard can be
|
||||||
|
// tested without a database, and so it cannot quietly grow a second dependency.
|
||||||
|
type webLocationChecker interface {
|
||||||
|
LocationAllowed(tenantID, locationID int) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebAuth verifies the console session and pins the request to its tenant.
|
||||||
|
func WebAuth(pos services.PosService) fiber.Handler { return webAuthWith(pos) }
|
||||||
|
|
||||||
|
func webAuthWith(locations webLocationChecker) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
if isPublicWebPath(c.Path()) {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
token := webBearerToken(c)
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
if webAuthRequired() {
|
||||||
|
return webUnauthorized(c, "a session token is required; sign in again")
|
||||||
|
}
|
||||||
|
// A console that predates tokens. Allowed through unpinned, which is
|
||||||
|
// exactly the state this middleware exists to end — see
|
||||||
|
// webAuthRequired.
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := utils.ParseWebToken(token, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
// Always refused, flag or no flag. A token that does not verify is a
|
||||||
|
// stronger signal than no token at all: nothing sends a broken one by
|
||||||
|
// accident.
|
||||||
|
return webUnauthorized(c, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nearle's own staff work across every tenant and legitimately name any
|
||||||
|
// of them. Checked once, here, rather than at each test below, so the
|
||||||
|
// exemption is a single visible branch instead of three.
|
||||||
|
if !claims.IsPlatformAccount() {
|
||||||
|
if requested := requestedTenant(c); requested > 0 && requested != claims.Tenantid {
|
||||||
|
return webForbidden(c, "this session cannot reach tenant "+strconv.Itoa(requested))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request can also scope by branch alone, naming no tenant at all,
|
||||||
|
// so pinning the tenant is not enough on its own.
|
||||||
|
if requested := requestedWebLocation(c); requested > 0 && requested != claims.Locationid {
|
||||||
|
allowed, err := locations.LocationAllowed(claims.Tenantid, requested)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
|
||||||
|
"code": http.StatusServiceUnavailable, "status": false,
|
||||||
|
"message": "could not verify branch access",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return webForbidden(c, "this session cannot reach branch "+strconv.Itoa(requested))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Locals(WebLocalsKey, claims)
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// webBearerToken reads the session out of the request.
|
||||||
|
//
|
||||||
|
// `Authorization: Bearer …` only. The POS reader next door also accepts
|
||||||
|
// `X-Pos-Token`, because shop routers between a till and this server strip
|
||||||
|
// Authorization headers on plain HTTP and a terminal that cannot authenticate
|
||||||
|
// is a shop that cannot trade. The console has no such problem — it is a
|
||||||
|
// browser on HTTPS — so it gets the one form, and a second accepted header is
|
||||||
|
// a second thing to get wrong.
|
||||||
|
func webBearerToken(c *fiber.Ctx) string {
|
||||||
|
header := strings.TrimSpace(c.Get("Authorization"))
|
||||||
|
if header == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if after, found := strings.CutPrefix(header, "Bearer "); found {
|
||||||
|
return strings.TrimSpace(after)
|
||||||
|
}
|
||||||
|
if !strings.Contains(header, " ") {
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestedTenant reads the tenant a request is naming, from wherever it put it.
|
||||||
|
//
|
||||||
|
// Query first, because that is where every `/web` list endpoint carries it, then
|
||||||
|
// the body, because the writes do not: `createdeliveries`, `publishproduct` and
|
||||||
|
// the rest post JSON. Checking only the query would leave every call that
|
||||||
|
// CHANGES another tenant's data unguarded, which is the wrong half to skip.
|
||||||
|
func requestedTenant(c *fiber.Ctx) int {
|
||||||
|
for _, key := range []string{"tenantid", "tenant_id"} {
|
||||||
|
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
|
||||||
|
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bodyScopeID(c, "tenantid", "tenant_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestedWebLocation reads the branch a request is naming.
|
||||||
|
//
|
||||||
|
// Separate from the POS reader's `requestedLocation` because the two surfaces
|
||||||
|
// spell it differently: POS routes use `store_id`, the console uses
|
||||||
|
// `locationid`. Both spellings are read here anyway — a shared endpoint is
|
||||||
|
// cheaper to allow for than to discover.
|
||||||
|
func requestedWebLocation(c *fiber.Ctx) int {
|
||||||
|
for _, key := range []string{"locationid", "location_id", "store_id"} {
|
||||||
|
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
|
||||||
|
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bodyScopeID(c, "locationid", "location_id", "store_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bodyScopeID pulls a scoping id out of a JSON request body.
|
||||||
|
//
|
||||||
|
// Decoded loosely rather than into a request type, on purpose: this runs before
|
||||||
|
// the handler and must not refuse anything the handler would have accepted. A
|
||||||
|
// body that will not parse here is left for the handler to reject with its own
|
||||||
|
// message, and a request shape that changes later must not silently stop being
|
||||||
|
// authorised.
|
||||||
|
//
|
||||||
|
// `c.Body()` returns buffered bytes, so reading here does not consume the
|
||||||
|
// stream the handler goes on to parse.
|
||||||
|
//
|
||||||
|
// An ARRAY body — `createdeliveries` posts one — is walked too. A batch naming
|
||||||
|
// another tenant in its elements is precisely the call worth guarding, and a
|
||||||
|
// probe that only understood objects would wave it through.
|
||||||
|
func bodyScopeID(c *fiber.Ctx, keys ...string) int {
|
||||||
|
body := c.Body()
|
||||||
|
if len(body) == 0 || len(body) > 8<<20 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw json.RawMessage = body
|
||||||
|
trimmed := strings.TrimLeft(string(body), " \t\r\n")
|
||||||
|
if strings.HasPrefix(trimmed, "[") {
|
||||||
|
var elements []json.RawMessage
|
||||||
|
if err := json.Unmarshal(body, &elements); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, element := range elements {
|
||||||
|
if id := scopeIDFromObject(element, keys); id > 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return scopeIDFromObject(raw, keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopeIDFromObject(raw json.RawMessage, keys []string) int {
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
if id := asScopeID(fields[key]); id > 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// asScopeID reads an id that may have been sent as a number or as a string.
|
||||||
|
//
|
||||||
|
// Both spellings are on the wire today — the console sends numbers, some app
|
||||||
|
// callers send strings — and a probe that understood only one would return 0
|
||||||
|
// for the other, which reads as "named no tenant" and waves the request past
|
||||||
|
// the check.
|
||||||
|
func asScopeID(raw json.RawMessage) int {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var number int
|
||||||
|
if err := json.Unmarshal(raw, &number); err == nil {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(raw, &text); err == nil {
|
||||||
|
if id, err := strconv.Atoi(strings.TrimSpace(text)); err == nil {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func webUnauthorized(c *fiber.Ctx, message string) error {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"code": http.StatusUnauthorized, "status": false, "message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func webForbidden(c *fiber.Ctx, message string) error {
|
||||||
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"code": http.StatusForbidden, "status": false, "message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebClaimsFrom returns the verified session on a request, if it carried one.
|
||||||
|
//
|
||||||
|
// The second return distinguishes "no token" from "a token claiming tenant 0",
|
||||||
|
// which is a platform account and a real answer. A handler that treated the two
|
||||||
|
// alike would give an unauthenticated caller the one session that reads
|
||||||
|
// everything.
|
||||||
|
func WebClaimsFrom(c *fiber.Ctx) (utils.WebClaims, bool) {
|
||||||
|
claims, ok := c.Locals(WebLocalsKey).(utils.WebClaims)
|
||||||
|
return claims, ok
|
||||||
|
}
|
||||||
273
middleware/webauth_test.go
Normal file
273
middleware/webauth_test.go
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const webTestSecret = "a-test-signing-key-long-enough"
|
||||||
|
|
||||||
|
// fakeLocations answers the tenant-owns-branch question without a database.
|
||||||
|
//
|
||||||
|
// `owned` is the branch the tenant genuinely has; anything else is refused, and
|
||||||
|
// `fails` makes the lookup itself error so the unavailable path can be reached.
|
||||||
|
type fakeLocations struct {
|
||||||
|
tenant int
|
||||||
|
owned int
|
||||||
|
fails bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeLocations) LocationAllowed(tenantID, locationID int) (bool, error) {
|
||||||
|
if f.fails {
|
||||||
|
return false, errFakeLookup
|
||||||
|
}
|
||||||
|
return tenantID == f.tenant && locationID == f.owned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeErr struct{}
|
||||||
|
|
||||||
|
func (fakeErr) Error() string { return "lookup unavailable" }
|
||||||
|
|
||||||
|
var errFakeLookup = fakeErr{}
|
||||||
|
|
||||||
|
// call runs one request through the middleware and reports the status.
|
||||||
|
//
|
||||||
|
// The handler behind it always succeeds, so any non-200 came from the guard.
|
||||||
|
func call(t *testing.T, locations webLocationChecker, token, method, target, body string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use("/live/api/v1/web", webAuthWith(locations))
|
||||||
|
app.All("/live/api/v1/web/*", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||||
|
if body != "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("calling: %v", err)
|
||||||
|
}
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenFor(t *testing.T, claims utils.WebClaims) string {
|
||||||
|
t.Helper()
|
||||||
|
token, _, err := utils.MintWebToken(claims, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The hole this exists to close ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestASessionCannotNameAnotherTenant(t *testing.T) {
|
||||||
|
// One number in a URL. Before this middleware it read another merchant's
|
||||||
|
// orders, stock, staff and takings.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
||||||
|
|
||||||
|
own := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
|
||||||
|
if own != fiber.StatusOK {
|
||||||
|
t.Fatalf("a session was refused its own tenant: %d", own)
|
||||||
|
}
|
||||||
|
|
||||||
|
other := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||||
|
if other != fiber.StatusForbidden {
|
||||||
|
t.Fatalf("tenant 916 was readable with a tenant 1147 session: %d", other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWriteCannotNameAnotherTenantInItsBody(t *testing.T) {
|
||||||
|
// The half that would be easy to skip. Reads carry `tenantid` in the query;
|
||||||
|
// the calls that CHANGE things post JSON, so a query-only check leaves every
|
||||||
|
// write unguarded.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
||||||
|
|
||||||
|
body := `{"tenantid":916,"productname":"Milk Bikis"}`
|
||||||
|
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/products/create", body)
|
||||||
|
if got != fiber.StatusForbidden {
|
||||||
|
t.Fatalf("a write into tenant 916 was allowed: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABatchCannotSmuggleAnotherTenantInAnArray(t *testing.T) {
|
||||||
|
// `createdeliveries` posts an array. A probe that only understood objects
|
||||||
|
// would wave through exactly the call that creates work in another
|
||||||
|
// merchant's shop.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
||||||
|
|
||||||
|
body := `[{"orderheaderid":1,"tenantid":916}]`
|
||||||
|
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/deliveries/createdeliveries", body)
|
||||||
|
if got != fiber.StatusForbidden {
|
||||||
|
t.Fatalf("a batch naming tenant 916 was allowed: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATenantSentAsAStringIsStillChecked(t *testing.T) {
|
||||||
|
// Both spellings are on the wire. A probe that understood only numbers
|
||||||
|
// returns 0 for `"916"`, which reads as "named no tenant" and passes.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/products/create", `{"tenantid":"916"}`)
|
||||||
|
if got != fiber.StatusForbidden {
|
||||||
|
t.Fatalf("a string tenant id slipped past: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scoping by branch alone ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestABranchMustBelongToTheSessionsTenant(t *testing.T) {
|
||||||
|
// A request can scope by branch and name no tenant at all, so pinning the
|
||||||
|
// tenant is not sufficient on its own.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
||||||
|
locations := fakeLocations{tenant: 1147, owned: 1173}
|
||||||
|
|
||||||
|
mine := call(t, locations, session, "GET", "/live/api/v1/web/products/get?locationid=1173", "")
|
||||||
|
if mine != fiber.StatusOK {
|
||||||
|
t.Fatalf("a second branch of my own tenant was refused: %d", mine)
|
||||||
|
}
|
||||||
|
|
||||||
|
theirs := call(t, locations, session, "GET", "/live/api/v1/web/products/get?locationid=1185", "")
|
||||||
|
if theirs != fiber.StatusForbidden {
|
||||||
|
t.Fatalf("another tenant's branch was readable: %d", theirs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSessionsOwnBranchNeedsNoLookup(t *testing.T) {
|
||||||
|
// `fails: true` errors on any lookup, so reaching OK proves the home branch
|
||||||
|
// short-circuits before asking.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{fails: true}, session, "GET", "/live/api/v1/web/products/get?locationid=1172", "")
|
||||||
|
if got != fiber.StatusOK {
|
||||||
|
t.Fatalf("the session's own branch was refused: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFailedBranchLookupIsNotAPass(t *testing.T) {
|
||||||
|
// If the check cannot run, the answer is "cannot verify", never "allowed".
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{fails: true}, session, "GET", "/live/api/v1/web/products/get?locationid=1185", "")
|
||||||
|
if got != fiber.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("a broken lookup did not refuse: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tokens ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestABrokenTokenIsAlwaysRefused(t *testing.T) {
|
||||||
|
// Refused whatever the flag says. Nothing sends a broken token by accident.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
t.Setenv("WEB_AUTH_REQUIRED", "false")
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, "w1.rubbish.signature", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
|
||||||
|
if got != fiber.StatusUnauthorized {
|
||||||
|
t.Fatalf("a forged token was not refused: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATillsTokenIsNotAConsoleSessionHere(t *testing.T) {
|
||||||
|
// A POS token is the same shape signed with the same key. If it verified
|
||||||
|
// here its `Locationid` would land where `Tenantid` is read.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
pos, _, err := utils.MintPosToken(utils.PosClaims{Userid: 7, Tenantid: 916, Locationid: 1185, Roleid: 8}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting a POS token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, pos, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
|
||||||
|
if got != fiber.StatusUnauthorized {
|
||||||
|
t.Fatalf("a cashier's token was accepted on the console: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The staged rollout ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestWithoutTheFlagAnUntokenedRequestStillWorks(t *testing.T) {
|
||||||
|
// The console in production sends no token yet. Locking it out before
|
||||||
|
// sign-in issues one would break a working product.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
t.Setenv("WEB_AUTH_REQUIRED", "false")
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||||
|
if got != fiber.StatusOK {
|
||||||
|
t.Fatalf("an untokened request was refused while enforcement is off: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTheFlagAnUntokenedRequestIsRefused(t *testing.T) {
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
t.Setenv("WEB_AUTH_REQUIRED", "true")
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||||
|
if got != fiber.StatusUnauthorized {
|
||||||
|
t.Fatalf("enforcement is on and an untokened request passed: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignInStillWorksWithEnforcementOn(t *testing.T) {
|
||||||
|
// Guarding the login route with a session token means nobody can ever get
|
||||||
|
// one. This is the test that catches a locked-out deployment.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
t.Setenv("WEB_AUTH_REQUIRED", "true")
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
"/live/api/v1/web/users/applogin",
|
||||||
|
"/live/api/v1/web/tenant/weblogin",
|
||||||
|
} {
|
||||||
|
if got := call(t, fakeLocations{}, "", "POST", path, `{"authname":"a@b.c"}`); got != fiber.StatusOK {
|
||||||
|
t.Fatalf("%s was locked behind a session: %d", path, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The platform account ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestPlatformStaffMayNameAnyTenant(t *testing.T) {
|
||||||
|
// Nearle's own staff work across tenants and the console's /nearle pages
|
||||||
|
// depend on it.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 12, Superadmin: true, Roleid: 1})
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||||
|
if got != fiber.StatusOK {
|
||||||
|
t.Fatalf("a platform session was refused tenant 916: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoTokenIsNotAPlatformAccount(t *testing.T) {
|
||||||
|
// Tenant 0 is the session that reads everything, and Go's zero value is 0.
|
||||||
|
// A handler reading claims off a request that carried none would hand an
|
||||||
|
// anonymous caller exactly that session.
|
||||||
|
app := fiber.New()
|
||||||
|
var found bool
|
||||||
|
app.Get("/probe", func(c *fiber.Ctx) error {
|
||||||
|
_, found = WebClaimsFrom(c)
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
})
|
||||||
|
if _, err := app.Test(httptest.NewRequest("GET", "/probe", nil)); err != nil {
|
||||||
|
t.Fatalf("probing: %v", err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
t.Fatal("claims were reported present on a request that carried none")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,16 @@ package models
|
|||||||
type ScanLookupRequest struct {
|
type ScanLookupRequest struct {
|
||||||
Customerid int `json:"customerid"`
|
Customerid int `json:"customerid"`
|
||||||
// What Lens read: "Milk Bikis", "Dabur Honey 500g". Free text, trimmed
|
// What Lens read: "Milk Bikis", "Dabur Honey 500g". Free text, trimmed
|
||||||
// and capped by the service.
|
// and capped by the service. Not required when Brand and Catalogueid
|
||||||
|
// name a product outright.
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
// A product the customer has already chosen, by its catalogue key —
|
||||||
|
// which is how the app resolves a `candidates` list from an earlier
|
||||||
|
// ambiguous lookup, and how a deep link or a re-order skips recognition
|
||||||
|
// altogether. When both are set the label is ignored and no catalogue
|
||||||
|
// search runs.
|
||||||
|
Brand string `json:"brand"`
|
||||||
|
Catalogueid int64 `json:"catalogueid"`
|
||||||
// Where the customer is right now. Optional: without it the customer's
|
// Where the customer is right now. Optional: without it the customer's
|
||||||
// saved primary address is used, and without that stores are listed in
|
// saved primary address is used, and without that stores are listed in
|
||||||
// registration order with no distance.
|
// registration order with no distance.
|
||||||
@@ -86,9 +94,15 @@ type ScanCatalogueMatch struct {
|
|||||||
VariantKey string `json:"variant_key,omitempty"`
|
VariantKey string `json:"variant_key,omitempty"`
|
||||||
Image string `json:"image,omitempty"`
|
Image string `json:"image,omitempty"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
// "vector", "vector+text" or "text" — how the score was produced. The app
|
// "vector+text", "text" or "direct" — how the score was produced. The app
|
||||||
// can be more cautious with a text-only match.
|
// can be more cautious with a text-only match; "direct" means the caller
|
||||||
|
// named the product by its catalogue key and nothing was recognised.
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
|
// Set only on entries of `candidates`: at least one of the customer's
|
||||||
|
// registered stores has this product in stock right now. Candidates are
|
||||||
|
// ordered with the available ones first, so a "did you mean?" list can
|
||||||
|
// show what is actually buyable before what is not.
|
||||||
|
Available bool `json:"available,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanLookupResponse is the answer to a scan.
|
// ScanLookupResponse is the answer to a scan.
|
||||||
@@ -96,10 +110,27 @@ type ScanLookupResponse struct {
|
|||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
// The best catalogue product for the label, and the sizes of it the
|
// The best catalogue product for the label, and the sizes of it the
|
||||||
// catalogue knows about (each a separate catalogue row).
|
// catalogue knows about (each a separate catalogue row).
|
||||||
|
//
|
||||||
|
// Match is nil when nothing was recognised, and also when several
|
||||||
|
// products matched equally well — see Ambiguous.
|
||||||
Match *ScanCatalogueMatch `json:"match"`
|
Match *ScanCatalogueMatch `json:"match"`
|
||||||
Variants []ScanCatalogueMatch `json:"catalogue_variants"`
|
Variants []ScanCatalogueMatch `json:"catalogue_variants"`
|
||||||
|
// Several products fit the label and no one of them is a clear winner —
|
||||||
|
// which is what a bare brand name ("britannia") or a generic word
|
||||||
|
// ("biscuits") produces, and Lens returns those often because a
|
||||||
|
// wordmark is the most legible thing on a packet.
|
||||||
|
//
|
||||||
|
// When true: Match is nil, Stores is empty, and Candidates holds the
|
||||||
|
// products to offer as "did you mean?". Picking one means calling
|
||||||
|
// /lookup again with that candidate's `brand` and `catalogueid`.
|
||||||
|
//
|
||||||
|
// Guessing instead would mean showing a confident price for a product
|
||||||
|
// the customer did not photograph.
|
||||||
|
Ambiguous bool `json:"ambiguous"`
|
||||||
|
Candidates []ScanCatalogueMatch `json:"candidates"`
|
||||||
// 0..1. Below ~0.5 the app should confirm with the customer before
|
// 0..1. Below ~0.5 the app should confirm with the customer before
|
||||||
// showing prices.
|
// showing prices. With Ambiguous set this is the leader's score, which
|
||||||
|
// by definition the runner-up nearly equals.
|
||||||
Confidence float64 `json:"confidence"`
|
Confidence float64 `json:"confidence"`
|
||||||
// Registered stores that stock the product, nearest first, in-stock
|
// Registered stores that stock the product, nearest first, in-stock
|
||||||
// first. Empty with Available=false when none does.
|
// first. Empty with Available=false when none does.
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ type ScanRepository interface {
|
|||||||
VectorSearch(ctx context.Context, vector []float32, limit int) ([]CatalogueHit, error)
|
VectorSearch(ctx context.Context, vector []float32, limit int) ([]CatalogueHit, error)
|
||||||
TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error)
|
TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error)
|
||||||
VectorSearchAvailable() bool
|
VectorSearchAvailable() bool
|
||||||
|
// CatalogueRef is one product named by its catalogue key, with its other
|
||||||
|
// pack sizes after it. Nothing is recognised or scored.
|
||||||
|
CatalogueRef(ctx context.Context, brand string, id int64) ([]CatalogueHit, error)
|
||||||
|
|
||||||
// cache
|
// cache
|
||||||
CachedVector(ctx context.Context, model, label string) ([]float32, bool)
|
CachedVector(ctx context.Context, model, label string) ([]float32, bool)
|
||||||
@@ -546,6 +549,72 @@ func (r *scanRepository) TextSearch(ctx context.Context, label string, limit int
|
|||||||
return hits, nil
|
return hits, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tableFor resolves a brand the caller named to a real catalogue table.
|
||||||
|
//
|
||||||
|
// The lookup is against the tables discovered from information_schema, never
|
||||||
|
// a string built from the request: table names cannot be parameterised in
|
||||||
|
// SQL, so the discovered map is what keeps this from being an injection
|
||||||
|
// point. Both the table suffix ("britannia") and a display name ("24 Mantra"
|
||||||
|
// → brand_24_mantra) resolve.
|
||||||
|
func (r *scanRepository) tableFor(ctx context.Context, brand string) (string, map[string]bool, error) {
|
||||||
|
tables, err := r.brandTables(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
for _, candidate := range []string{
|
||||||
|
"brand_" + strings.ToLower(strings.TrimSpace(brand)),
|
||||||
|
"brand_" + normaliseBrandKey(brand),
|
||||||
|
} {
|
||||||
|
if cols, ok := tables[candidate]; ok {
|
||||||
|
return candidate, cols, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil, ErrUnknownBrand
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogueRef reads one product by (brand, id) and appends its other pack
|
||||||
|
// sizes — same variant_key where the catalogue assigned one, same name
|
||||||
|
// otherwise, matching how the search groups a family.
|
||||||
|
//
|
||||||
|
// Distance is 0 on every row: nothing here was ranked, the caller said which
|
||||||
|
// product they meant.
|
||||||
|
func (r *scanRepository) CatalogueRef(ctx context.Context, brand string, id int64) ([]CatalogueHit, error) {
|
||||||
|
table, cols, err := r.tableFor(ctx, brand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
suffix := strings.TrimPrefix(table, "brand_")
|
||||||
|
columns := hitColumns(suffix, cols)
|
||||||
|
|
||||||
|
var self []CatalogueHit
|
||||||
|
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
|
||||||
|
`SELECT %s, 0::float8 AS distance FROM %s WHERE id = ?`, columns, table), id).Scan(&self).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(self) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var siblings []CatalogueHit
|
||||||
|
if cols["variant_key"] && strings.TrimSpace(self[0].VariantKey) != "" {
|
||||||
|
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
|
||||||
|
`SELECT %s, 0::float8 AS distance FROM %s WHERE variant_key = ? AND id <> ? ORDER BY id`,
|
||||||
|
columns, table), self[0].VariantKey, id).Scan(&siblings).Error
|
||||||
|
} else {
|
||||||
|
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
|
||||||
|
`SELECT %s, 0::float8 AS distance FROM %s WHERE LOWER(product_name) = LOWER(?) AND id <> ? ORDER BY id`,
|
||||||
|
columns, table), self[0].ProductName, id).Scan(&siblings).Error
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// The product itself was found; losing its other sizes is the smaller
|
||||||
|
// failure and the caller asked for this one.
|
||||||
|
log.Printf("scan: could not read pack sizes of %s#%d: %v", brand, id, err)
|
||||||
|
return self, nil
|
||||||
|
}
|
||||||
|
return append(self, siblings...), nil
|
||||||
|
}
|
||||||
|
|
||||||
func sortedKeys(m map[string]map[string]bool) []string {
|
func sortedKeys(m map[string]map[string]bool) []string {
|
||||||
keys := make([]string, 0, len(m))
|
keys := make([]string, 0, len(m))
|
||||||
for k := range m {
|
for k := range m {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package routes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"nearle/facade"
|
"nearle/facade"
|
||||||
|
"nearle/middleware"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -10,6 +11,22 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
|
|||||||
|
|
||||||
api := app.Group("/live/api")
|
api := app.Group("/live/api")
|
||||||
|
|
||||||
|
// Console sessions.
|
||||||
|
//
|
||||||
|
// Mounted by PATH rather than on a group object, because the `/v1/web`
|
||||||
|
// routes are not one group — a dozen files each create their own
|
||||||
|
// (`/v1/web/users`, `/v1/web/orders`, `/v1/web/products`, …). Registered
|
||||||
|
// here, ahead of all of them, so a route added later is guarded by default
|
||||||
|
// rather than by somebody remembering to.
|
||||||
|
//
|
||||||
|
// `/v1/pos` is deliberately NOT covered: that is the terminal surface, it
|
||||||
|
// carries a different kind of token, and it has its own guard. But
|
||||||
|
// `/v1/web/pos` and `/v1/web/tenants` ARE, despite their names — both are
|
||||||
|
// console callers, and `createposuser` on the second mints till credentials,
|
||||||
|
// which until now it did on the strength of an unauthenticated request. The
|
||||||
|
// note above registerPosStaffConsoleRoutes asked for exactly this.
|
||||||
|
api.Use("/v1/web", middleware.WebAuth(f.PosService()))
|
||||||
|
|
||||||
RegisterUserRoutes(api, f)
|
RegisterUserRoutes(api, f)
|
||||||
RegisterProductRoutes(api, f)
|
RegisterProductRoutes(api, f)
|
||||||
RegisterOrderRoutes(api, f)
|
RegisterOrderRoutes(api, f)
|
||||||
|
|||||||
@@ -53,28 +53,29 @@ func main() {
|
|||||||
|
|
||||||
var cols []struct {
|
var cols []struct {
|
||||||
Relname string
|
Relname string
|
||||||
|
Attname string
|
||||||
Typname string
|
Typname string
|
||||||
Atttypmod int
|
Atttypmod int
|
||||||
}
|
}
|
||||||
if err := db.Raw(`
|
if err := db.Raw(`
|
||||||
SELECT c.relname, t.typname, a.atttypmod
|
SELECT c.relname, a.attname, t.typname, a.atttypmod
|
||||||
FROM pg_attribute a
|
FROM pg_attribute a
|
||||||
JOIN pg_class c ON c.oid = a.attrelid
|
JOIN pg_class c ON c.oid = a.attrelid
|
||||||
JOIN pg_type t ON t.oid = a.atttypid
|
JOIN pg_type t ON t.oid = a.atttypid
|
||||||
WHERE a.attname = 'embedding' AND c.relname LIKE 'brand\_%'
|
WHERE t.typname = 'vector' AND a.attnum > 0 AND c.relname LIKE 'brand\_%'
|
||||||
ORDER BY c.relname`).Scan(&cols).Error; err != nil {
|
ORDER BY c.relname, a.attname`).Scan(&cols).Error; err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(cols) == 0 {
|
if len(cols) == 0 {
|
||||||
fmt.Println("no brand_* table has an embedding column")
|
fmt.Println("no brand_* table has a vector column")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf("%-28s %-8s %5s %5s %5s\n", "table", "type", "dims", "rows", "embd")
|
fmt.Printf("%-24s %-16s %-8s %5s %5s %5s\n", "table", "column", "type", "dims", "rows", "filled")
|
||||||
for _, c := range cols {
|
for _, c := range cols {
|
||||||
var total, filled int64
|
var total, filled int64
|
||||||
db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s`, c.Relname)).Scan(&total)
|
db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s`, c.Relname)).Scan(&total)
|
||||||
db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s WHERE embedding IS NOT NULL`, c.Relname)).Scan(&filled)
|
db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s WHERE %s IS NOT NULL`, c.Relname, c.Attname)).Scan(&filled)
|
||||||
fmt.Printf("%-28s %-8s %5d %5d %5d\n", c.Relname, c.Typname, c.Atttypmod, total, filled)
|
fmt.Printf("%-24s %-16s %-8s %5d %5d %5d\n", c.Relname, c.Attname, c.Typname, c.Atttypmod, total, filled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// nomic/bge emit unit vectors; a norm far from 1 means another pipeline.
|
// nomic/bge emit unit vectors; a norm far from 1 means another pipeline.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
"nearle/utils"
|
"nearle/utils"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -54,6 +55,12 @@ const (
|
|||||||
// came back as "Paneer Makhni 500ml" (0.304) — a near-miss on an
|
// came back as "Paneer Makhni 500ml" (0.304) — a near-miss on an
|
||||||
// unrelated row clears a floor set that close to the noise.
|
// unrelated row clears a floor set that close to the noise.
|
||||||
scanMinScore = 0.50
|
scanMinScore = 0.50
|
||||||
|
// How close the runner-up may be before the leader stops being an answer
|
||||||
|
// and the two become a question. See isAmbiguous.
|
||||||
|
scanAmbiguityMargin = 0.06
|
||||||
|
// A "did you mean?" list longer than this is not a choice, it is a
|
||||||
|
// catalogue — the customer is standing in a shop holding a packet.
|
||||||
|
scanMaxCandidates = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
// ScanErrors the controller maps to statuses. Everything else is a 500.
|
// ScanErrors the controller maps to statuses. Everything else is a 500.
|
||||||
@@ -82,11 +89,15 @@ func NewScanService(repo repositories.ScanRepository, embedder utils.Embedder) S
|
|||||||
|
|
||||||
func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest) (*models.ScanLookupResponse, error) {
|
func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest) (*models.ScanLookupResponse, error) {
|
||||||
label := strings.TrimSpace(req.Label)
|
label := strings.TrimSpace(req.Label)
|
||||||
|
// The caller can name the product outright instead of describing it —
|
||||||
|
// how the app resolves a candidate the customer picked.
|
||||||
|
direct := strings.TrimSpace(req.Brand) != "" && req.Catalogueid > 0
|
||||||
|
|
||||||
if req.Customerid <= 0 {
|
if req.Customerid <= 0 {
|
||||||
return nil, fmt.Errorf("%w: customerid is required", ErrScanBadRequest)
|
return nil, fmt.Errorf("%w: customerid is required", ErrScanBadRequest)
|
||||||
}
|
}
|
||||||
if label == "" {
|
if label == "" && !direct {
|
||||||
return nil, fmt.Errorf("%w: label is required", ErrScanBadRequest)
|
return nil, fmt.Errorf("%w: label, or brand and catalogueid, is required", ErrScanBadRequest)
|
||||||
}
|
}
|
||||||
if len(label) > scanMaxLabelLen {
|
if len(label) > scanMaxLabelLen {
|
||||||
label = label[:scanMaxLabelLen]
|
label = label[:scanMaxLabelLen]
|
||||||
@@ -126,6 +137,10 @@ func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest)
|
|||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
if direct {
|
||||||
|
hits, method, matchErr = s.resolveRef(ctx, req.Brand, req.Catalogueid)
|
||||||
|
return
|
||||||
|
}
|
||||||
hits, method, matchErr = s.searchCatalogue(ctx, label)
|
hits, method, matchErr = s.searchCatalogue(ctx, label)
|
||||||
}()
|
}()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
@@ -149,18 +164,32 @@ func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest)
|
|||||||
Label: label,
|
Label: label,
|
||||||
Stores: []models.ScanStoreOffer{},
|
Stores: []models.ScanStoreOffer{},
|
||||||
Variants: []models.ScanCatalogueMatch{},
|
Variants: []models.ScanCatalogueMatch{},
|
||||||
|
Candidates: []models.ScanCatalogueMatch{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the app's idea of the customer's tenants against the truth.
|
// Verify the app's idea of the customer's tenants against the truth.
|
||||||
stores, resp.UnregisteredTenantids = restrictToTenants(stores, req.Tenantids)
|
stores, resp.UnregisteredTenantids = restrictToTenants(stores, req.Tenantids)
|
||||||
|
|
||||||
if len(hits) == 0 || hits[0].score < scanMinScore {
|
switch {
|
||||||
|
case len(hits) == 0 && direct:
|
||||||
|
resp.Message = "That product is no longer in the catalogue."
|
||||||
|
return resp, nil
|
||||||
|
case len(hits) == 0, hits[0].score < scanMinScore:
|
||||||
resp.Message = "We couldn't recognise that product. Try a clearer photo of the front of the pack."
|
resp.Message = "We couldn't recognise that product. Try a clearer photo of the front of the pack."
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
best := hits[0]
|
distinct := distinctProducts(hits)
|
||||||
family := catalogueFamily(hits)
|
|
||||||
|
// Several products fit and none of them clearly wins — a bare brand name
|
||||||
|
// or a generic word. Ask rather than guess: naming one of them would put
|
||||||
|
// a confident price on a product the customer did not photograph.
|
||||||
|
if !direct && isAmbiguous(distinct) {
|
||||||
|
return s.candidatesResponse(ctx, resp, hits, distinct, method, stores)
|
||||||
|
}
|
||||||
|
|
||||||
|
best := distinct[0]
|
||||||
|
family := catalogueFamily(hits, best)
|
||||||
resp.Match = ptr(best.toMatch(method))
|
resp.Match = ptr(best.toMatch(method))
|
||||||
resp.Confidence = round3(best.score)
|
resp.Confidence = round3(best.score)
|
||||||
for _, h := range family {
|
for _, h := range family {
|
||||||
@@ -179,11 +208,7 @@ func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest)
|
|||||||
keys = append(keys, repositories.CatalogueKey{Brand: h.Brand, Catalogueid: h.ID, Imageid: h.ImageID})
|
keys = append(keys, repositories.CatalogueKey{Brand: h.Brand, Catalogueid: h.ID, Imageid: h.ImageID})
|
||||||
names = append(names, h.ProductName)
|
names = append(names, h.ProductName)
|
||||||
}
|
}
|
||||||
locationids := make([]int, 0, len(stores))
|
rows, err := s.repo.StoreOptions(ctx, locationIDs(stores), keys, names)
|
||||||
for _, st := range stores {
|
|
||||||
locationids = append(locationids, st.Locationid)
|
|
||||||
}
|
|
||||||
rows, err := s.repo.StoreOptions(ctx, locationids, keys, names)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -213,6 +238,137 @@ func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest)
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// candidatesResponse answers an ambiguous label with the products to choose
|
||||||
|
// between, marking which of them the customer can actually buy right now.
|
||||||
|
//
|
||||||
|
// The availability read is the same StoreOptions query the confident path
|
||||||
|
// runs, widened to every candidate — so a "did you mean?" list can put the
|
||||||
|
// three that are in stock above the five that are not, instead of sending
|
||||||
|
// somebody to a shelf that has none of them.
|
||||||
|
func (s *scanService) candidatesResponse(ctx context.Context, resp *models.ScanLookupResponse,
|
||||||
|
hits, distinct []scoredHit, method string, stores []models.ScanStore) (*models.ScanLookupResponse, error) {
|
||||||
|
|
||||||
|
candidates := distinct
|
||||||
|
if len(candidates) > scanMaxCandidates {
|
||||||
|
candidates = candidates[:scanMaxCandidates]
|
||||||
|
}
|
||||||
|
resp.Ambiguous = true
|
||||||
|
resp.Confidence = round3(distinct[0].score)
|
||||||
|
|
||||||
|
// Every catalogue row belonging to a candidate, and a way back from what
|
||||||
|
// a tenant's product row carries to the candidate it stands for.
|
||||||
|
inCandidates := make(map[string]bool, len(candidates))
|
||||||
|
for _, c := range candidates {
|
||||||
|
inCandidates[c.productKey()] = true
|
||||||
|
}
|
||||||
|
var keys []repositories.CatalogueKey
|
||||||
|
var names []string
|
||||||
|
byImage := make(map[string]string)
|
||||||
|
byRef := make(map[string]string)
|
||||||
|
byName := make(map[string]string)
|
||||||
|
for _, h := range hits {
|
||||||
|
key := h.productKey()
|
||||||
|
if !inCandidates[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, repositories.CatalogueKey{Brand: h.Brand, Catalogueid: h.ID, Imageid: h.ImageID})
|
||||||
|
names = append(names, h.ProductName)
|
||||||
|
if h.ImageID != "" {
|
||||||
|
byImage[h.ImageID] = key
|
||||||
|
}
|
||||||
|
byRef[refKey(h.Brand, h.ID)] = key
|
||||||
|
if n := strings.ToLower(strings.TrimSpace(h.ProductName)); n != "" {
|
||||||
|
byName[n] = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stocked := make(map[string]bool)
|
||||||
|
if len(stores) > 0 && len(keys) > 0 {
|
||||||
|
rows, err := s.repo.StoreOptions(ctx, locationIDs(stores), keys, names)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Stock <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Same precedence as optionFromRow: the stable key first.
|
||||||
|
if row.Imageid != "" {
|
||||||
|
if key, ok := byImage[row.Imageid]; ok {
|
||||||
|
stocked[key] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.Catalogueid > 0 {
|
||||||
|
if key, ok := byRef[refKey(row.Productbrand, row.Catalogueid)]; ok {
|
||||||
|
stocked[key] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key, ok := byName[strings.ToLower(strings.TrimSpace(row.Productname))]; ok {
|
||||||
|
stocked[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range candidates {
|
||||||
|
m := c.toMatch(method)
|
||||||
|
m.Available = stocked[c.productKey()]
|
||||||
|
resp.Candidates = append(resp.Candidates, m)
|
||||||
|
}
|
||||||
|
// Buyable first; within each group the search's own ranking stands.
|
||||||
|
sort.SliceStable(resp.Candidates, func(i, j int) bool {
|
||||||
|
return resp.Candidates[i].Available && !resp.Candidates[j].Available
|
||||||
|
})
|
||||||
|
|
||||||
|
available := 0
|
||||||
|
for _, c := range resp.Candidates {
|
||||||
|
if c.Available {
|
||||||
|
available++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if available > 0 {
|
||||||
|
resp.Message = fmt.Sprintf("Which one is it? %d of these %d are in stock near you.",
|
||||||
|
available, len(resp.Candidates))
|
||||||
|
} else {
|
||||||
|
resp.Message = fmt.Sprintf("Which one is it? We found %d products that could match.",
|
||||||
|
len(resp.Candidates))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRef reads the product the caller named, with its pack sizes. No
|
||||||
|
// recognition, so every row scores 1 and the method says so.
|
||||||
|
func (s *scanService) resolveRef(ctx context.Context, brand string, id int64) ([]scoredHit, string, error) {
|
||||||
|
rows, err := s.repo.CatalogueRef(ctx, brand, id)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, repositories.ErrCatalogueDBUnavailable):
|
||||||
|
return nil, "direct", ErrScanCatalogueDown
|
||||||
|
case errors.Is(err, repositories.ErrUnknownBrand):
|
||||||
|
return nil, "direct", fmt.Errorf("%w: unknown brand %q", ErrScanBadRequest, brand)
|
||||||
|
}
|
||||||
|
return nil, "direct", err
|
||||||
|
}
|
||||||
|
hits := make([]scoredHit, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
hits = append(hits, scoredHit{CatalogueHit: row, score: 1})
|
||||||
|
}
|
||||||
|
return hits, "direct", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func refKey(brand string, id int64) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(brand)) + "#" + strconv.FormatInt(id, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationIDs(stores []models.ScanStore) []int {
|
||||||
|
ids := make([]int, 0, len(stores))
|
||||||
|
for _, st := range stores {
|
||||||
|
ids = append(ids, st.Locationid)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
// ── Confirm ─────────────────────────────────────────────────────────────────
|
// ── Confirm ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (s *scanService) Confirm(ctx context.Context, req models.ScanConfirmRequest) (*models.ScanConfirmResponse, error) {
|
func (s *scanService) Confirm(ctx context.Context, req models.ScanConfirmRequest) (*models.ScanConfirmResponse, error) {
|
||||||
@@ -530,6 +686,11 @@ func (s *scanService) embed(ctx context.Context, label string) ([]float32, error
|
|||||||
// The whole label as a substring of the name is near-certain; otherwise the
|
// The whole label as a substring of the name is near-certain; otherwise the
|
||||||
// share of label words found in name+title, scaled so that "all of them"
|
// share of label words found in name+title, scaled so that "all of them"
|
||||||
// stops short of the substring case.
|
// stops short of the substring case.
|
||||||
|
//
|
||||||
|
// A label that is a substring of MANY names — a bare brand, "britannia" —
|
||||||
|
// therefore scores them all 0.95, identically. That tie is not a flaw to
|
||||||
|
// score around: it is the signal, and isAmbiguous reads it to answer "did
|
||||||
|
// you mean?" rather than letting the sort order pick a winner.
|
||||||
func textScore(h repositories.CatalogueHit, label string, tokens []string) float64 {
|
func textScore(h repositories.CatalogueHit, label string, tokens []string) float64 {
|
||||||
name := strings.ToLower(h.ProductName)
|
name := strings.ToLower(h.ProductName)
|
||||||
hay := name + " " + strings.ToLower(h.Title)
|
hay := name + " " + strings.ToLower(h.Title)
|
||||||
@@ -561,27 +722,64 @@ func textScore(h repositories.CatalogueHit, label string, tokens []string) float
|
|||||||
return 0.8 * float64(found) / float64(len(tokens))
|
return 0.8 * float64(found) / float64(len(tokens))
|
||||||
}
|
}
|
||||||
|
|
||||||
// catalogueFamily is the best hit and its other pack sizes: same brand, and
|
// productKey identifies a product across its pack sizes: the catalogue's own
|
||||||
// the same variant_key when the catalogue assigned one, else the same name.
|
// variant_key where it assigned one, the name otherwise, always within a
|
||||||
// Every member is a separate catalogue row a shop may have imported.
|
// brand. Two rows sharing it are 100 g and 200 g of one thing; two rows that
|
||||||
func catalogueFamily(hits []scoredHit) []scoredHit {
|
// do not are different products to choose between.
|
||||||
if len(hits) == 0 {
|
func productKey(h repositories.CatalogueHit) string {
|
||||||
return nil
|
if k := strings.TrimSpace(h.VariantKey); k != "" {
|
||||||
|
return h.Brand + "/" + strings.ToLower(k)
|
||||||
}
|
}
|
||||||
best := hits[0]
|
return h.Brand + "/" + strings.ToLower(strings.TrimSpace(h.ProductName))
|
||||||
family := []scoredHit{best}
|
}
|
||||||
for _, h := range hits[1:] {
|
|
||||||
if h.Brand != best.Brand {
|
// productKey of a scored hit — scoredHit embeds the row it scored.
|
||||||
|
func (h scoredHit) productKey() string { return productKey(h.CatalogueHit) }
|
||||||
|
|
||||||
|
// distinctProducts keeps the best-scoring row of each product, in rank
|
||||||
|
// order — the list of things the customer could actually be shown to choose
|
||||||
|
// between, as opposed to the same product listed four times in four sizes.
|
||||||
|
func distinctProducts(hits []scoredHit) []scoredHit {
|
||||||
|
seen := make(map[string]bool, len(hits))
|
||||||
|
out := make([]scoredHit, 0, len(hits))
|
||||||
|
for _, h := range hits {
|
||||||
|
key := h.productKey()
|
||||||
|
if seen[key] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch {
|
seen[key] = true
|
||||||
case best.VariantKey != "" && h.VariantKey != "":
|
out = append(out, h)
|
||||||
if h.VariantKey == best.VariantKey {
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAmbiguous reports that naming the leader as THE match would be a guess
|
||||||
|
// dressed up as an answer, because something else is level with it.
|
||||||
|
//
|
||||||
|
// A margin rather than an absolute threshold: what matters is not how high
|
||||||
|
// the best score is but whether anything is tied with it. A bare brand name
|
||||||
|
// is a substring of every one of that brand's names, so textScore gives them
|
||||||
|
// all 0.95 — a perfect tie at a HIGH score, which no floor would catch.
|
||||||
|
//
|
||||||
|
// Erring towards asking is deliberate. Asking costs the customer one tap on
|
||||||
|
// a picture; guessing wrong costs them the wrong biscuit and costs us the
|
||||||
|
// belief that the scanner works. A label that names one product leaves the
|
||||||
|
// runner-up far behind, so the common case is unaffected.
|
||||||
|
func isAmbiguous(distinct []scoredHit) bool {
|
||||||
|
return len(distinct) >= 2 && distinct[1].score >= distinct[0].score-scanAmbiguityMargin
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogueFamily is `of` and its other pack sizes, drawn from hits.
|
||||||
|
func catalogueFamily(hits []scoredHit, of scoredHit) []scoredHit {
|
||||||
|
key := of.productKey()
|
||||||
|
family := make([]scoredHit, 0, 4)
|
||||||
|
for _, h := range hits {
|
||||||
|
if h.productKey() == key {
|
||||||
family = append(family, h)
|
family = append(family, h)
|
||||||
}
|
}
|
||||||
case strings.EqualFold(strings.TrimSpace(h.ProductName), strings.TrimSpace(best.ProductName)):
|
|
||||||
family = append(family, h)
|
|
||||||
}
|
}
|
||||||
|
if len(family) == 0 {
|
||||||
|
return []scoredHit{of}
|
||||||
}
|
}
|
||||||
return family
|
return family
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
|
"nearle/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -30,9 +33,13 @@ type fakeScanRepo struct {
|
|||||||
options []repositories.StoreOptionRow
|
options []repositories.StoreOptionRow
|
||||||
at map[int]*repositories.StoreOptionRow // productid → row
|
at map[int]*repositories.StoreOptionRow // productid → row
|
||||||
|
|
||||||
|
ref []repositories.CatalogueHit
|
||||||
|
refErr error
|
||||||
|
|
||||||
askedKeys []repositories.CatalogueKey
|
askedKeys []repositories.CatalogueKey
|
||||||
askedNames []string
|
askedNames []string
|
||||||
askedLocs []int
|
askedLocs []int
|
||||||
|
askedRef string
|
||||||
cachedHits map[string][]repositories.CatalogueHit
|
cachedHits map[string][]repositories.CatalogueHit
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +76,10 @@ func (f *fakeScanRepo) TextSearch(context.Context, string, int) ([]repositories.
|
|||||||
return f.text, nil
|
return f.text, nil
|
||||||
}
|
}
|
||||||
func (f *fakeScanRepo) VectorSearchAvailable() bool { return f.hasVec }
|
func (f *fakeScanRepo) VectorSearchAvailable() bool { return f.hasVec }
|
||||||
|
func (f *fakeScanRepo) CatalogueRef(_ context.Context, brand string, id int64) ([]repositories.CatalogueHit, error) {
|
||||||
|
f.askedRef = fmt.Sprintf("%s#%d", brand, id)
|
||||||
|
return f.ref, f.refErr
|
||||||
|
}
|
||||||
func (f *fakeScanRepo) CachedVector(context.Context, string, string) ([]float32, bool) {
|
func (f *fakeScanRepo) CachedVector(context.Context, string, string) ([]float32, bool) {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -423,7 +434,7 @@ func TestCatalogueFamilyGroupsByVariantKeyThenName(t *testing.T) {
|
|||||||
{CatalogueHit: milkBikis200, score: 0.88},
|
{CatalogueHit: milkBikis200, score: 0.88},
|
||||||
{CatalogueHit: repositories.CatalogueHit{Brand: "parle", ProductName: "Milk Bikis", VariantKey: "milk_bikis"}, score: 0.5},
|
{CatalogueHit: repositories.CatalogueHit{Brand: "parle", ProductName: "Milk Bikis", VariantKey: "milk_bikis"}, score: 0.5},
|
||||||
}
|
}
|
||||||
family := catalogueFamily(hits)
|
family := catalogueFamily(hits, hits[0])
|
||||||
if len(family) != 2 || family[1].ID != 8 {
|
if len(family) != 2 || family[1].ID != 8 {
|
||||||
t.Fatalf("family should be the two britannia sizes, got %+v", family)
|
t.Fatalf("family should be the two britannia sizes, got %+v", family)
|
||||||
}
|
}
|
||||||
@@ -432,11 +443,229 @@ func TestCatalogueFamilyGroupsByVariantKeyThenName(t *testing.T) {
|
|||||||
a := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 1, ProductName: "Honey"}}
|
a := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 1, ProductName: "Honey"}}
|
||||||
b := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 2, ProductName: "honey "}}
|
b := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 2, ProductName: "honey "}}
|
||||||
c := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 3, ProductName: "Honey Lite"}}
|
c := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 3, ProductName: "Honey Lite"}}
|
||||||
if family := catalogueFamily([]scoredHit{a, b, c}); len(family) != 2 {
|
if family := catalogueFamily([]scoredHit{a, b, c}, a); len(family) != 2 {
|
||||||
t.Errorf("name match should join 1 and 2 only, got %+v", family)
|
t.Errorf("name match should join 1 and 2 only, got %+v", family)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Ambiguity.
|
||||||
|
|
||||||
|
Lens hands back whatever was most legible on the packet, and on a packet that
|
||||||
|
is very often the brand wordmark alone. "britannia" fits 258 catalogue rows
|
||||||
|
equally well, so there is no best one. textScore gives all of them 0.95 —
|
||||||
|
correctly, the label IS in every one of those names — and with nothing to
|
||||||
|
read that tie, the sort order picked a winner and the customer was shown one
|
||||||
|
arbitrary biscuit with "confidence": 0.95 and a price. These tests are the
|
||||||
|
contract that it asks instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Three different Britannia products, of which the customer's stores stock
|
||||||
|
// one. Text-only: no embedder, which is also how production runs until the
|
||||||
|
// model is configured.
|
||||||
|
func newBrandLabelFixture() *fakeScanRepo {
|
||||||
|
cashew := repositories.CatalogueHit{Brand: "britannia", ID: 21, ProductName: "Britannia Good Day Cashew Cookies", VariantKey: "good_day_cashew", ImageID: "britannia_good_day_cashew"}
|
||||||
|
butter := repositories.CatalogueHit{Brand: "britannia", ID: 22, ProductName: "Britannia Good Day Butter Cookies", VariantKey: "good_day_butter", ImageID: "britannia_good_day_butter"}
|
||||||
|
marie := repositories.CatalogueHit{Brand: "britannia", ID: 23, ProductName: "Britannia Marie Gold", VariantKey: "marie_gold", ImageID: "britannia_marie_gold"}
|
||||||
|
|
||||||
|
return &fakeScanRepo{
|
||||||
|
exists: true,
|
||||||
|
stores: fixtureStores(),
|
||||||
|
text: []repositories.CatalogueHit{cashew, butter, marie},
|
||||||
|
options: []repositories.StoreOptionRow{
|
||||||
|
{Tenantid: 2, Locationid: 20, Productid: 220, Productname: "Britannia Marie Gold",
|
||||||
|
Productbrand: "britannia", Catalogueid: 23, Imageid: "britannia_marie_gold", Price: 30, Stock: 4},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABareBrandNameAsksInsteadOfGuessing(t *testing.T) {
|
||||||
|
repo := newBrandLabelFixture()
|
||||||
|
svc := NewScanService(repo, nil)
|
||||||
|
|
||||||
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
||||||
|
Customerid: 5, Label: "britannia", Latitude: "11.035", Longitude: "77.035",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Ambiguous {
|
||||||
|
t.Fatalf("a bare brand name must not resolve to one product, got match %+v", resp.Match)
|
||||||
|
}
|
||||||
|
if resp.Match != nil {
|
||||||
|
t.Errorf("Match must be nil while ambiguous, got %+v", resp.Match)
|
||||||
|
}
|
||||||
|
if len(resp.Stores) != 0 {
|
||||||
|
t.Errorf("no store or price may be quoted for a product the customer has not chosen, got %d offers", len(resp.Stores))
|
||||||
|
}
|
||||||
|
if len(resp.Variants) != 0 {
|
||||||
|
t.Errorf("pack sizes belong to a chosen product, got %+v", resp.Variants)
|
||||||
|
}
|
||||||
|
if len(resp.Candidates) != 3 {
|
||||||
|
t.Fatalf("want the three distinct Britannia products, got %d: %+v", len(resp.Candidates), resp.Candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one the customer can actually buy is offered first.
|
||||||
|
if !resp.Candidates[0].Available || resp.Candidates[0].Catalogueid != 23 {
|
||||||
|
t.Errorf("the stocked product should lead the list, got %+v", resp.Candidates[0])
|
||||||
|
}
|
||||||
|
for _, c := range resp.Candidates[1:] {
|
||||||
|
if c.Available {
|
||||||
|
t.Errorf("only Marie Gold is stocked, but %s reports available", c.ProductName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Note what confidence does NOT say here. The label appears verbatim in
|
||||||
|
// all three names, so relevance is high — and the answer is still a
|
||||||
|
// question. An app that gated on `confidence` instead of `ambiguous`
|
||||||
|
// would show a price for the wrong biscuit, which is the whole bug.
|
||||||
|
if resp.Confidence < 0.9 {
|
||||||
|
t.Errorf("a verbatim brand match scores high; %v suggests the scoring changed", resp.Confidence)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Message, "Which one") {
|
||||||
|
t.Errorf("the message should ask, got %q", resp.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAmbiguousLabelWithNoStockStillLists(t *testing.T) {
|
||||||
|
repo := newBrandLabelFixture()
|
||||||
|
repo.options = nil
|
||||||
|
svc := NewScanService(repo, nil)
|
||||||
|
|
||||||
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "britannia"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !resp.Ambiguous || len(resp.Candidates) != 3 {
|
||||||
|
t.Fatalf("want three candidates, got ambiguous=%v %d", resp.Ambiguous, len(resp.Candidates))
|
||||||
|
}
|
||||||
|
for _, c := range resp.Candidates {
|
||||||
|
if c.Available {
|
||||||
|
t.Errorf("%s cannot be available with no stock anywhere", c.ProductName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half of the contract: a label that does name a product must not
|
||||||
|
// start asking questions.
|
||||||
|
func TestASpecificLabelStillWinsOutright(t *testing.T) {
|
||||||
|
repo := newBrandLabelFixture()
|
||||||
|
svc := NewScanService(repo, nil)
|
||||||
|
|
||||||
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
||||||
|
Customerid: 5, Label: "good day cashew",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Ambiguous {
|
||||||
|
t.Fatalf("a label naming one product should resolve, got candidates %+v", resp.Candidates)
|
||||||
|
}
|
||||||
|
if resp.Match == nil || resp.Match.Catalogueid != 21 {
|
||||||
|
t.Fatalf("want the cashew cookies, got %+v", resp.Match)
|
||||||
|
}
|
||||||
|
if len(resp.Candidates) != 0 {
|
||||||
|
t.Errorf("candidates belong to an ambiguous answer, got %+v", resp.Candidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The property the ambiguity check rests on: a label that is a substring of
|
||||||
|
// several names scores them EQUALLY. Nothing downstream can tell "did you
|
||||||
|
// mean?" from "found it" if a formula breaks that tie on name length, word
|
||||||
|
// count or anything else incidental — which is how one arbitrary Britannia
|
||||||
|
// biscuit used to come back with a price on it.
|
||||||
|
func TestABrandNameScoresItsProductsIdentically(t *testing.T) {
|
||||||
|
cashew := repositories.CatalogueHit{ProductName: "Britannia Good Day Cashew Cookies 200g"}
|
||||||
|
butter := repositories.CatalogueHit{ProductName: "Britannia Good Day Butter Cookies 100g"}
|
||||||
|
// Deliberately a much shorter name: length must not become a tie-breaker.
|
||||||
|
marie := repositories.CatalogueHit{ProductName: "Britannia Marie Gold"}
|
||||||
|
|
||||||
|
tokens := utils.SearchTokens("britannia")
|
||||||
|
a, b, c := textScore(cashew, "britannia", tokens), textScore(butter, "britannia", tokens), textScore(marie, "britannia", tokens)
|
||||||
|
if a != b || b != c {
|
||||||
|
t.Fatalf("a brand must score its products equally, got %.3f / %.3f / %.3f", a, b, c)
|
||||||
|
}
|
||||||
|
if a == 0 {
|
||||||
|
t.Fatal("the brand name is in every one of those names; scoring it 0 would hide them all")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a label that does name a product must NOT tie with its siblings,
|
||||||
|
// or everything would be a question.
|
||||||
|
specific := utils.SearchTokens("good day cashew")
|
||||||
|
if textScore(cashew, "good day cashew", specific) <= textScore(butter, "good day cashew", specific) {
|
||||||
|
t.Error("a label naming one product must outscore its siblings")
|
||||||
|
}
|
||||||
|
|
||||||
|
if none := textScore(cashew, "dabur honey", utils.SearchTokens("dabur honey")); none != 0 {
|
||||||
|
t.Errorf("nothing in common should score 0, got %.3f", none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDistinctProductsCollapsesPackSizes(t *testing.T) {
|
||||||
|
hits := []scoredHit{
|
||||||
|
{CatalogueHit: milkBikis, score: 1},
|
||||||
|
{CatalogueHit: milkBikis200, score: 0.9},
|
||||||
|
{CatalogueHit: goodDay, score: 0.5},
|
||||||
|
}
|
||||||
|
distinct := distinctProducts(hits)
|
||||||
|
if len(distinct) != 2 || distinct[0].ID != 7 || distinct[1].ID != 9 {
|
||||||
|
t.Fatalf("two sizes of one product are one choice, got %+v", distinct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picking a candidate: the app sends the key instead of a description, and
|
||||||
|
// nothing is recognised at all.
|
||||||
|
func TestNamingTheProductSkipsRecognition(t *testing.T) {
|
||||||
|
repo := newLookupFixture()
|
||||||
|
repo.ref = []repositories.CatalogueHit{milkBikis, milkBikis200}
|
||||||
|
// If recognition ran, these would decide the answer instead.
|
||||||
|
repo.text = []repositories.CatalogueHit{goodDay}
|
||||||
|
repo.vector = []repositories.CatalogueHit{goodDay}
|
||||||
|
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
|
||||||
|
|
||||||
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
||||||
|
Customerid: 5, Brand: "britannia", Catalogueid: 7,
|
||||||
|
Latitude: "11.035", Longitude: "77.035",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if repo.askedRef != "britannia#7" {
|
||||||
|
t.Fatalf("the catalogue should have been asked for that exact product, got %q", repo.askedRef)
|
||||||
|
}
|
||||||
|
if resp.Match == nil || resp.Match.Catalogueid != 7 || resp.Match.Method != "direct" {
|
||||||
|
t.Fatalf("want a direct match on 7, got %+v", resp.Match)
|
||||||
|
}
|
||||||
|
if resp.Ambiguous || resp.Confidence != 1 {
|
||||||
|
t.Errorf("a named product is not a guess: ambiguous=%v confidence=%v", resp.Ambiguous, resp.Confidence)
|
||||||
|
}
|
||||||
|
if len(resp.Variants) != 2 {
|
||||||
|
t.Errorf("its pack sizes should come with it, got %+v", resp.Variants)
|
||||||
|
}
|
||||||
|
if !resp.Available || resp.RecommendedLocationid != 20 {
|
||||||
|
t.Errorf("stores are resolved exactly as for a recognised product, got %+v", resp.Stores)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMissingCatalogueRefIsNotAMatch(t *testing.T) {
|
||||||
|
repo := newLookupFixture()
|
||||||
|
repo.ref = nil
|
||||||
|
svc := NewScanService(repo, nil)
|
||||||
|
|
||||||
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
||||||
|
Customerid: 5, Brand: "britannia", Catalogueid: 999,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Match != nil || resp.Ambiguous || len(resp.Stores) != 0 {
|
||||||
|
t.Fatalf("a product that is gone is not a match, got %+v", resp)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Message, "no longer") {
|
||||||
|
t.Errorf("the message should say the product is gone, got %q", resp.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A vector neighbour that is merely not-quite-unrelated used to clear the old
|
// A vector neighbour that is merely not-quite-unrelated used to clear the old
|
||||||
// 0.30 floor: in production "Paracetamol" came back as "Paneer Makhni 500ml"
|
// 0.30 floor: in production "Paracetamol" came back as "Paneer Makhni 500ml"
|
||||||
// on a 0.304 similarity. Correct labels land near 0.92, so nothing this weak
|
// on a 0.304 similarity. Correct labels land near 0.92, so nothing this weak
|
||||||
|
|||||||
162
services/tools/audit.go
Normal file
162
services/tools/audit.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The audit trail.
|
||||||
|
//
|
||||||
|
// One row per call, including every refusal — the refusals are the interesting
|
||||||
|
// ones. A registry that recorded only successes would answer "did anything try
|
||||||
|
// to read another tenant?" with silence, which reads the same as "no".
|
||||||
|
//
|
||||||
|
// ── Why the sink is an interface ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Phase 1 writes to the log, because a table is a migration and this needs to
|
||||||
|
// work before that lands. Nothing else in the package knows that: the registry
|
||||||
|
// holds an `AuditSink`, so the database sink arrives later as a second
|
||||||
|
// implementation and no call site changes.
|
||||||
|
//
|
||||||
|
// ── Writes record intent, not outcome ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Reads record what happened, which is all a read can be asked for. When write
|
||||||
|
// tools arrive they must record the ATTEMPT before the call leaves, not the
|
||||||
|
// result after it returns: a crash mid-write has to leave a trace that it was
|
||||||
|
// tried, and a row written only on success is a row that is missing exactly
|
||||||
|
// when it is needed.
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutcomeOK = "ok"
|
||||||
|
OutcomeRefused = "refused"
|
||||||
|
OutcomeFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditEntry is one attempt to use a tool.
|
||||||
|
type AuditEntry struct {
|
||||||
|
At time.Time
|
||||||
|
Agent string
|
||||||
|
Tool string
|
||||||
|
Scope string
|
||||||
|
Userid int
|
||||||
|
Tenantid int
|
||||||
|
// The arguments as the handler received them — validated and defaulted, not
|
||||||
|
// as the model sent them. What actually ran is what is worth keeping.
|
||||||
|
Args map[string]any
|
||||||
|
// ok | refused | failed. `refused` is the guard saying no; `failed` is the
|
||||||
|
// handler breaking. Collapsing the two would hide a broken tool inside a
|
||||||
|
// count of things working as designed.
|
||||||
|
Outcome string
|
||||||
|
Detail string
|
||||||
|
Rows int
|
||||||
|
Took time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditSink is where entries go.
|
||||||
|
//
|
||||||
|
// No error returned, deliberately. An audit sink that can fail a call gives a
|
||||||
|
// full disk the power to take the assistant down; one that cannot means a lost
|
||||||
|
// row, which is worse in theory and better in practice. A sink that cares
|
||||||
|
// should retry or buffer internally.
|
||||||
|
type AuditSink interface {
|
||||||
|
Write(ctx context.Context, entry AuditEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscardAudit keeps nothing. For tests that are not about the audit trail.
|
||||||
|
type DiscardAudit struct{}
|
||||||
|
|
||||||
|
func (DiscardAudit) Write(context.Context, AuditEntry) {}
|
||||||
|
|
||||||
|
// LogAudit writes one line per call to the standard logger.
|
||||||
|
//
|
||||||
|
// A line rather than JSON per field, because this is read by a person tailing
|
||||||
|
// logs during the rollout. The database sink can be structured.
|
||||||
|
type LogAudit struct{}
|
||||||
|
|
||||||
|
func (LogAudit) Write(_ context.Context, entry AuditEntry) {
|
||||||
|
log.Printf("assistant: %s", entry.Line())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line renders an entry for a log.
|
||||||
|
//
|
||||||
|
// Arguments are rendered sorted so two identical calls produce identical lines
|
||||||
|
// and a grep for one of them finds both. Go's map iteration is randomised, so
|
||||||
|
// without the sort the same call logs differently every time.
|
||||||
|
func (e AuditEntry) Line() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(e.Outcome)
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(e.Agent)
|
||||||
|
b.WriteString("/")
|
||||||
|
b.WriteString(e.Tool)
|
||||||
|
|
||||||
|
if e.Tenantid > 0 {
|
||||||
|
b.WriteString(" tenant=")
|
||||||
|
b.WriteString(itoa(e.Tenantid))
|
||||||
|
} else {
|
||||||
|
// Explicitly, rather than by omission: "no tenant" on an assistant call
|
||||||
|
// is either staff or a bug, and both are worth being able to search for.
|
||||||
|
b.WriteString(" tenant=none")
|
||||||
|
}
|
||||||
|
b.WriteString(" user=")
|
||||||
|
b.WriteString(itoa(e.Userid))
|
||||||
|
|
||||||
|
if len(e.Args) > 0 {
|
||||||
|
keys := make([]string, 0, len(e.Args))
|
||||||
|
for key := range e.Args {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
value, err := json.Marshal(e.Args[key])
|
||||||
|
if err != nil {
|
||||||
|
value = []byte("?")
|
||||||
|
}
|
||||||
|
parts = append(parts, key+"="+string(value))
|
||||||
|
}
|
||||||
|
b.WriteString(" args{")
|
||||||
|
b.WriteString(strings.Join(parts, " "))
|
||||||
|
b.WriteString("}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Outcome == OutcomeOK {
|
||||||
|
b.WriteString(" rows=")
|
||||||
|
b.WriteString(itoa(e.Rows))
|
||||||
|
}
|
||||||
|
if e.Detail != "" {
|
||||||
|
b.WriteString(" detail=")
|
||||||
|
value, err := json.Marshal(e.Detail)
|
||||||
|
if err != nil {
|
||||||
|
b.WriteString("?")
|
||||||
|
} else {
|
||||||
|
b.Write(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(" took=")
|
||||||
|
b.WriteString(e.Took.Round(time.Millisecond).String())
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
value, _ := json.Marshal(n)
|
||||||
|
return string(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectAudit keeps entries in memory, for tests that ARE about the trail.
|
||||||
|
type CollectAudit struct{ Entries []AuditEntry }
|
||||||
|
|
||||||
|
func (c *CollectAudit) Write(_ context.Context, entry AuditEntry) {
|
||||||
|
c.Entries = append(c.Entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CollectAudit) Last() (AuditEntry, bool) {
|
||||||
|
if len(c.Entries) == 0 {
|
||||||
|
return AuditEntry{}, false
|
||||||
|
}
|
||||||
|
return c.Entries[len(c.Entries)-1], true
|
||||||
|
}
|
||||||
431
services/tools/registry.go
Normal file
431
services/tools/registry.go
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
// Package tools is the registry every assistant call goes through.
|
||||||
|
//
|
||||||
|
// An agent does not reach the database. It names a tool, and this package
|
||||||
|
// decides whether that is allowed, whether the arguments make sense, who is
|
||||||
|
// asking, and what gets recorded — then runs a handler that was written by a
|
||||||
|
// person and tested.
|
||||||
|
//
|
||||||
|
// ── Why a registry rather than generated SQL ────────────────────────────────
|
||||||
|
//
|
||||||
|
// The usual reason is safety. Here there is a harder one: the fields on this
|
||||||
|
// backend do not mean what their names say, and it is measured and documented.
|
||||||
|
// `orders.deliverystatus` is an empty string on all 181 rows of tenant 1147.
|
||||||
|
// `orders.orderstatus` only ever carries pending, delivered or cancelled, so
|
||||||
|
// the six delivery stages in between never reach it. `deliveries.ridername`
|
||||||
|
// holds delivery statuses as often as names. `orders.deliverytype` is empty on
|
||||||
|
// every row in production, so filtering on it hides the entire list.
|
||||||
|
// `billedat` is local wall-clock labelled `Z`, which puts 19 of 20 bills in the
|
||||||
|
// future.
|
||||||
|
//
|
||||||
|
// A model writing SQL gets every one of those wrong, confidently, with no
|
||||||
|
// error — it reports a cancel rate from a column of empty strings and nobody
|
||||||
|
// can tell. A model calling a tool cannot, because the correction lives inside
|
||||||
|
// the handler with the measurement that justified it written beside it.
|
||||||
|
//
|
||||||
|
// So: no agent gets raw table access, and a tool that accepts a `where` string
|
||||||
|
// is a table with extra steps.
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scope separates reads from writes.
|
||||||
|
//
|
||||||
|
// Not decoration. A write tool goes through human approval before it runs
|
||||||
|
// (that is a later phase), and the registry is where the two are told apart, so
|
||||||
|
// the distinction has to be declared on the tool rather than inferred from its
|
||||||
|
// name.
|
||||||
|
type Scope string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScopeRead Scope = "read"
|
||||||
|
ScopeWrite Scope = "write"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind is the type of one argument. Deliberately three.
|
||||||
|
//
|
||||||
|
// Enough for every tool that exists, and small enough that the validator is
|
||||||
|
// readable in one sitting. A tool wanting a nested object is a tool doing two
|
||||||
|
// things.
|
||||||
|
type Kind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindInt Kind = "integer"
|
||||||
|
KindString Kind = "string"
|
||||||
|
KindBool Kind = "boolean"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Field is one argument a tool accepts.
|
||||||
|
//
|
||||||
|
// `Max == 0` means unbounded, which is why no field here wants a negative
|
||||||
|
// range — none do, and a pointer per bound to express "unset" would cost every
|
||||||
|
// call site clarity to buy a case that has not come up.
|
||||||
|
type Field struct {
|
||||||
|
Name string
|
||||||
|
// What the model reads to decide what to put here. Written for the model,
|
||||||
|
// not for a developer: "minutes a job may sit unaccepted before it counts
|
||||||
|
// as stuck" beats "threshold".
|
||||||
|
Description string
|
||||||
|
Kind Kind
|
||||||
|
Required bool
|
||||||
|
Min, Max int
|
||||||
|
Default any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema is a tool's argument contract.
|
||||||
|
type Schema struct{ Fields []Field }
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnknownTool = errors.New("no such tool")
|
||||||
|
ErrNotAllowed = errors.New("this agent may not use that tool")
|
||||||
|
ErrBadArgument = errors.New("argument is not valid")
|
||||||
|
ErrNoTenant = errors.New("caller names no tenant")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate checks arguments and fills in defaults.
|
||||||
|
//
|
||||||
|
// Returns a NEW map rather than editing the caller's, and the returned map is
|
||||||
|
// what the handler sees. Anything not declared is dropped rather than passed
|
||||||
|
// through — a handler must never receive a key it did not ask for, or an
|
||||||
|
// argument the model invented becomes an argument the handler might one day
|
||||||
|
// start reading.
|
||||||
|
func (s Schema) Validate(args map[string]any) (map[string]any, error) {
|
||||||
|
clean := make(map[string]any, len(s.Fields))
|
||||||
|
|
||||||
|
for _, field := range s.Fields {
|
||||||
|
raw, sent := args[field.Name]
|
||||||
|
if !sent || raw == nil {
|
||||||
|
if field.Required {
|
||||||
|
return nil, fmt.Errorf("%w: %s is required", ErrBadArgument, field.Name)
|
||||||
|
}
|
||||||
|
if field.Default != nil {
|
||||||
|
clean[field.Name] = field.Default
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := coerce(field, raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clean[field.Name] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return clean, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// coerce turns what arrived into what the field declared.
|
||||||
|
//
|
||||||
|
// JSON numbers arrive as float64 whatever they looked like on the wire, so an
|
||||||
|
// integer field has to accept one and check it is whole. Reading it as an int
|
||||||
|
// directly would fail every call made over HTTP, which is all of them.
|
||||||
|
func coerce(field Field, raw any) (any, error) {
|
||||||
|
switch field.Kind {
|
||||||
|
case KindInt:
|
||||||
|
var n int
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case int:
|
||||||
|
n = v
|
||||||
|
case int64:
|
||||||
|
n = int(v)
|
||||||
|
case float64:
|
||||||
|
if v != float64(int(v)) {
|
||||||
|
return nil, fmt.Errorf("%w: %s must be a whole number", ErrBadArgument, field.Name)
|
||||||
|
}
|
||||||
|
n = int(v)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: %s must be a number", ErrBadArgument, field.Name)
|
||||||
|
}
|
||||||
|
if n < field.Min {
|
||||||
|
return nil, fmt.Errorf("%w: %s must be at least %d", ErrBadArgument, field.Name, field.Min)
|
||||||
|
}
|
||||||
|
if field.Max > 0 && n > field.Max {
|
||||||
|
return nil, fmt.Errorf("%w: %s must be at most %d", ErrBadArgument, field.Name, field.Max)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
|
||||||
|
case KindString:
|
||||||
|
text, ok := raw.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %s must be text", ErrBadArgument, field.Name)
|
||||||
|
}
|
||||||
|
if field.Max > 0 && len(text) > field.Max {
|
||||||
|
return nil, fmt.Errorf("%w: %s is longer than %d characters", ErrBadArgument, field.Name, field.Max)
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
|
|
||||||
|
case KindBool:
|
||||||
|
flag, ok := raw.(bool)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %s must be true or false", ErrBadArgument, field.Name)
|
||||||
|
}
|
||||||
|
return flag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("%w: %s has no type", ErrBadArgument, field.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONSchema renders the contract in the form a model and MCP both expect.
|
||||||
|
//
|
||||||
|
// Kept as a projection of `Schema` rather than the source of truth, so the
|
||||||
|
// validator and the description a model is given cannot drift: there is one
|
||||||
|
// declaration and this is a view of it.
|
||||||
|
func (s Schema) JSONSchema() map[string]any {
|
||||||
|
properties := map[string]any{}
|
||||||
|
required := []string{}
|
||||||
|
|
||||||
|
for _, field := range s.Fields {
|
||||||
|
property := map[string]any{
|
||||||
|
"type": string(field.Kind),
|
||||||
|
"description": field.Description,
|
||||||
|
}
|
||||||
|
if field.Kind == KindInt {
|
||||||
|
property["minimum"] = field.Min
|
||||||
|
if field.Max > 0 {
|
||||||
|
property["maximum"] = field.Max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if field.Default != nil {
|
||||||
|
property["default"] = field.Default
|
||||||
|
}
|
||||||
|
properties[field.Name] = property
|
||||||
|
if field.Required {
|
||||||
|
required = append(required, field.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(required)
|
||||||
|
schema := map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": properties,
|
||||||
|
// The model may not invent arguments. A tool that tolerated extras
|
||||||
|
// would make the schema a suggestion.
|
||||||
|
"additionalProperties": false,
|
||||||
|
}
|
||||||
|
if len(required) > 0 {
|
||||||
|
schema["required"] = required
|
||||||
|
}
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller is the verified session a tool runs on behalf of.
|
||||||
|
//
|
||||||
|
// Built from `middleware.WebAuth`'s claims and never from anything the model
|
||||||
|
// said. That is the whole arrangement: the model chooses the tool and the
|
||||||
|
// arguments, and has no say at all in whose data it reads.
|
||||||
|
type Caller struct {
|
||||||
|
Userid int
|
||||||
|
Tenantid int
|
||||||
|
Locationid int
|
||||||
|
// Nearle staff, from `app_users.issuperadmin` — the only thing that reads
|
||||||
|
// across tenants. NOT a role: `app_roles` calls roleid 1 "Super admin" and
|
||||||
|
// tenant onboarding wrote 1 for every shop owner.
|
||||||
|
Superadmin bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request is what a handler receives.
|
||||||
|
type Request struct {
|
||||||
|
Args map[string]any
|
||||||
|
Caller Caller
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int reads a validated integer argument.
|
||||||
|
//
|
||||||
|
// No error return, on purpose: by the time a handler runs, the schema has
|
||||||
|
// already refused anything that is not an int in range, so a second check here
|
||||||
|
// would be unreachable code that still has to be read.
|
||||||
|
func (r Request) Int(name string) int {
|
||||||
|
if v, ok := r.Args[name].(int); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Request) String(name string) string {
|
||||||
|
if v, ok := r.Args[name].(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Request) Bool(name string) bool {
|
||||||
|
if v, ok := r.Args[name].(bool); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is what a tool answers with.
|
||||||
|
//
|
||||||
|
// Rows, never prose. The handler returns structured data and the model does the
|
||||||
|
// phrasing; a handler that wrote sentences would mean two layers formatting the
|
||||||
|
// same fact, and they drift.
|
||||||
|
type Result struct {
|
||||||
|
// Concrete typed slices, marshalled by the caller. `any` here so handlers
|
||||||
|
// stay typed rather than every one of them building maps.
|
||||||
|
Rows any
|
||||||
|
Count int
|
||||||
|
// True when there were more rows than were returned, with `Note` saying so
|
||||||
|
// in words. An empty answer and a capped answer look identical to a model,
|
||||||
|
// and it will describe both as "none".
|
||||||
|
Truncated bool
|
||||||
|
Note string
|
||||||
|
// The console route showing the same rows, so an answer can carry a link to
|
||||||
|
// what proves it. Buddy states a conclusion; this is where a person checks.
|
||||||
|
Source string
|
||||||
|
// What the answer covers — "all branches", or a named one. A tool that
|
||||||
|
// omits it lets an answer about one branch be read as an answer about all.
|
||||||
|
Scope string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent is a caller's allow-list.
|
||||||
|
//
|
||||||
|
// The registry refuses a tool an agent has not named, rather than the model
|
||||||
|
// declining to call it. A prompt is a request; this is a rule.
|
||||||
|
type Agent struct {
|
||||||
|
Name string
|
||||||
|
Tools []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Agent) Allows(tool string) bool {
|
||||||
|
for _, name := range a.Tools {
|
||||||
|
if name == tool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool is one thing an agent can do.
|
||||||
|
type Tool struct {
|
||||||
|
Name string
|
||||||
|
// What the model reads to choose between tools. The most load-bearing
|
||||||
|
// string in the package: a vague one produces a model that calls the wrong
|
||||||
|
// tool and explains the wrong number confidently.
|
||||||
|
Description string
|
||||||
|
Scope Scope
|
||||||
|
Schema Schema
|
||||||
|
Handler func(ctx context.Context, req Request) (Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry holds the tools and is the only way to reach one.
|
||||||
|
type Registry struct {
|
||||||
|
tools map[string]Tool
|
||||||
|
audit AuditSink
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(audit AuditSink) *Registry {
|
||||||
|
if audit == nil {
|
||||||
|
audit = DiscardAudit{}
|
||||||
|
}
|
||||||
|
return &Registry{tools: map[string]Tool{}, audit: audit, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds a tool. Duplicate names are refused rather than overwritten:
|
||||||
|
// silently replacing a tool is how a permission check disappears.
|
||||||
|
func (r *Registry) Register(t Tool) error {
|
||||||
|
if t.Name == "" {
|
||||||
|
return errors.New("a tool needs a name")
|
||||||
|
}
|
||||||
|
if t.Handler == nil {
|
||||||
|
return fmt.Errorf("tool %q has no handler", t.Name)
|
||||||
|
}
|
||||||
|
if t.Description == "" {
|
||||||
|
return fmt.Errorf("tool %q has no description; a model cannot choose it", t.Name)
|
||||||
|
}
|
||||||
|
if _, taken := r.tools[t.Name]; taken {
|
||||||
|
return fmt.Errorf("tool %q is already registered", t.Name)
|
||||||
|
}
|
||||||
|
r.tools[t.Name] = t
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definitions describes the tools one agent may use, for a model or for MCP.
|
||||||
|
//
|
||||||
|
// Built from the agent's allow-list rather than from everything registered, so
|
||||||
|
// a model is never told about a tool it would then be refused — which reads to
|
||||||
|
// a model as a malfunction and to a person as the assistant being broken.
|
||||||
|
func (r *Registry) Definitions(agent Agent) []map[string]any {
|
||||||
|
names := make([]string, 0, len(agent.Tools))
|
||||||
|
for _, name := range agent.Tools {
|
||||||
|
if _, ok := r.tools[name]; ok {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
out := make([]map[string]any, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
tool := r.tools[name]
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"name": tool.Name,
|
||||||
|
"description": tool.Description,
|
||||||
|
"input_schema": tool.Schema.JSONSchema(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call is the one entry point, and it does five things in this order:
|
||||||
|
// find the tool, check the agent may use it, validate the arguments, confirm
|
||||||
|
// the caller is scoped to something, and run the handler — recording exactly
|
||||||
|
// one audit row whatever happens, including every refusal.
|
||||||
|
//
|
||||||
|
// The order matters. Arguments are validated before the handler sees them so a
|
||||||
|
// handler never defends itself, and the caller is checked before the handler
|
||||||
|
// runs so a tool cannot forget to.
|
||||||
|
func (r *Registry) Call(ctx context.Context, agent Agent, name string, args map[string]any, caller Caller) (Result, error) {
|
||||||
|
started := r.now()
|
||||||
|
entry := AuditEntry{
|
||||||
|
At: started,
|
||||||
|
Agent: agent.Name,
|
||||||
|
Tool: name,
|
||||||
|
Userid: caller.Userid,
|
||||||
|
Tenantid: caller.Tenantid,
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
|
||||||
|
finish := func(result Result, outcome string, detail string, err error) (Result, error) {
|
||||||
|
entry.Outcome = outcome
|
||||||
|
entry.Detail = detail
|
||||||
|
entry.Rows = result.Count
|
||||||
|
entry.Took = r.now().Sub(started)
|
||||||
|
r.audit.Write(ctx, entry)
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, known := r.tools[name]
|
||||||
|
if !known {
|
||||||
|
return finish(Result{}, OutcomeRefused, "unknown tool", fmt.Errorf("%w: %s", ErrUnknownTool, name))
|
||||||
|
}
|
||||||
|
entry.Scope = string(tool.Scope)
|
||||||
|
|
||||||
|
if !agent.Allows(name) {
|
||||||
|
return finish(Result{}, OutcomeRefused, "not on the agent's allow-list", fmt.Errorf("%w: %s cannot use %s", ErrNotAllowed, agent.Name, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
clean, err := tool.Schema.Validate(args)
|
||||||
|
if err != nil {
|
||||||
|
return finish(Result{}, OutcomeRefused, err.Error(), err)
|
||||||
|
}
|
||||||
|
entry.Args = clean
|
||||||
|
|
||||||
|
// A caller scoped to nothing must not be treated as a caller scoped to
|
||||||
|
// everything. Go's zero value is 0, so an unset tenant and a platform
|
||||||
|
// account look identical unless staff status is asked for separately.
|
||||||
|
if caller.Tenantid <= 0 && !caller.Superadmin {
|
||||||
|
return finish(Result{}, OutcomeRefused, "no tenant on the caller", ErrNoTenant)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := tool.Handler(ctx, Request{Args: clean, Caller: caller})
|
||||||
|
if err != nil {
|
||||||
|
return finish(Result{}, OutcomeFailed, err.Error(), err)
|
||||||
|
}
|
||||||
|
return finish(result, OutcomeOK, "", nil)
|
||||||
|
}
|
||||||
327
services/tools/registry_test.go
Normal file
327
services/tools/registry_test.go
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The registry is the only way to reach a tool, so these are the checks that
|
||||||
|
// stand between a model and the data. Each one is a thing the model could ask
|
||||||
|
// for and must not get.
|
||||||
|
|
||||||
|
func okTool(name string) Tool {
|
||||||
|
return Tool{
|
||||||
|
Name: name,
|
||||||
|
Description: "a tool, for testing",
|
||||||
|
Scope: ScopeRead,
|
||||||
|
Schema: Schema{Fields: []Field{{
|
||||||
|
Name: "limit", Description: "how many", Kind: KindInt, Min: 1, Max: 50, Default: 10,
|
||||||
|
}}},
|
||||||
|
Handler: func(_ context.Context, req Request) (Result, error) {
|
||||||
|
return Result{Rows: []int{1, 2}, Count: 2, Scope: "all branches"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryWith(t *testing.T, tools ...Tool) (*Registry, *CollectAudit) {
|
||||||
|
t.Helper()
|
||||||
|
audit := &CollectAudit{}
|
||||||
|
r := New(audit)
|
||||||
|
for _, tool := range tools {
|
||||||
|
if err := r.Register(tool); err != nil {
|
||||||
|
t.Fatalf("registering %s: %v", tool.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r, audit
|
||||||
|
}
|
||||||
|
|
||||||
|
var anyone = Caller{Userid: 904, Tenantid: 1147}
|
||||||
|
|
||||||
|
/* ── The five jobs ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestAToolRunsAndReturnsRows(t *testing.T) {
|
||||||
|
r, _ := registryWith(t, okTool("thing"))
|
||||||
|
agent := Agent{Name: "orders", Tools: []string{"thing"}}
|
||||||
|
|
||||||
|
result, err := r.Call(context.Background(), agent, "thing", nil, anyone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("calling: %v", err)
|
||||||
|
}
|
||||||
|
if result.Count != 2 {
|
||||||
|
t.Fatalf("rows lost: %d", result.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnknownToolIsRefused(t *testing.T) {
|
||||||
|
r, _ := registryWith(t, okTool("thing"))
|
||||||
|
agent := Agent{Name: "orders", Tools: []string{"thing"}}
|
||||||
|
|
||||||
|
_, err := r.Call(context.Background(), agent, "invented", nil, anyone)
|
||||||
|
if !errors.Is(err, ErrUnknownTool) {
|
||||||
|
t.Fatalf("a tool the model made up was not refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAToolOffTheAllowListIsRefusedByTheRegistryNotTheModel(t *testing.T) {
|
||||||
|
// The whole point of the allow-list: a prompt is a request, this is a rule.
|
||||||
|
r, _ := registryWith(t, okTool("thing"), okTool("other"))
|
||||||
|
agent := Agent{Name: "orders", Tools: []string{"thing"}}
|
||||||
|
|
||||||
|
_, err := r.Call(context.Background(), agent, "other", nil, anyone)
|
||||||
|
if !errors.Is(err, ErrNotAllowed) {
|
||||||
|
t.Fatalf("an agent reached a tool it does not name: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadArgumentsNeverReachTheHandler(t *testing.T) {
|
||||||
|
reached := false
|
||||||
|
tool := okTool("thing")
|
||||||
|
tool.Handler = func(context.Context, Request) (Result, error) {
|
||||||
|
reached = true
|
||||||
|
return Result{}, nil
|
||||||
|
}
|
||||||
|
r, _ := registryWith(t, tool)
|
||||||
|
agent := Agent{Name: "orders", Tools: []string{"thing"}}
|
||||||
|
|
||||||
|
_, err := r.Call(context.Background(), agent, "thing", map[string]any{"limit": 999}, anyone)
|
||||||
|
if !errors.Is(err, ErrBadArgument) {
|
||||||
|
t.Fatalf("an out-of-range argument was accepted: %v", err)
|
||||||
|
}
|
||||||
|
if reached {
|
||||||
|
t.Fatal("the handler ran on arguments the schema refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACallerScopedToNothingIsNotACallerScopedToEverything(t *testing.T) {
|
||||||
|
// Go's zero value is 0, so an unset tenant and a platform account look
|
||||||
|
// identical unless staff status is asked for separately.
|
||||||
|
r, _ := registryWith(t, okTool("thing"))
|
||||||
|
agent := Agent{Name: "orders", Tools: []string{"thing"}}
|
||||||
|
|
||||||
|
_, err := r.Call(context.Background(), agent, "thing", nil, Caller{Userid: 904})
|
||||||
|
if !errors.Is(err, ErrNoTenant) {
|
||||||
|
t.Fatalf("a caller with no tenant was let through: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := r.Call(context.Background(), agent, "thing", nil, Caller{Userid: 12, Superadmin: true}); err != nil {
|
||||||
|
t.Fatalf("staff were refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Arguments ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestDefaultsAreFilledIn(t *testing.T) {
|
||||||
|
var seen Request
|
||||||
|
tool := okTool("thing")
|
||||||
|
tool.Handler = func(_ context.Context, req Request) (Result, error) {
|
||||||
|
seen = req
|
||||||
|
return Result{}, nil
|
||||||
|
}
|
||||||
|
r, _ := registryWith(t, tool)
|
||||||
|
|
||||||
|
if _, err := r.Call(context.Background(), Agent{Name: "a", Tools: []string{"thing"}}, "thing", nil, anyone); err != nil {
|
||||||
|
t.Fatalf("calling: %v", err)
|
||||||
|
}
|
||||||
|
if seen.Int("limit") != 10 {
|
||||||
|
t.Fatalf("the default did not arrive: %d", seen.Int("limit"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAJSONNumberIsAcceptedAsAnInteger(t *testing.T) {
|
||||||
|
// Every argument arrives over HTTP, so an integer field that only accepted
|
||||||
|
// Go ints would refuse every real call.
|
||||||
|
var seen Request
|
||||||
|
tool := okTool("thing")
|
||||||
|
tool.Handler = func(_ context.Context, req Request) (Result, error) {
|
||||||
|
seen = req
|
||||||
|
return Result{}, nil
|
||||||
|
}
|
||||||
|
r, _ := registryWith(t, tool)
|
||||||
|
|
||||||
|
if _, err := r.Call(context.Background(), Agent{Name: "a", Tools: []string{"thing"}}, "thing", map[string]any{"limit": float64(20)}, anyone); err != nil {
|
||||||
|
t.Fatalf("a JSON number was refused: %v", err)
|
||||||
|
}
|
||||||
|
if seen.Int("limit") != 20 {
|
||||||
|
t.Fatalf("the value did not survive: %d", seen.Int("limit"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFractionIsNotAWholeNumber(t *testing.T) {
|
||||||
|
r, _ := registryWith(t, okTool("thing"))
|
||||||
|
_, err := r.Call(context.Background(), Agent{Name: "a", Tools: []string{"thing"}}, "thing", map[string]any{"limit": 2.5}, anyone)
|
||||||
|
if !errors.Is(err, ErrBadArgument) {
|
||||||
|
t.Fatalf("2.5 was accepted as a count: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnInventedArgumentIsDroppedNotPassedOn(t *testing.T) {
|
||||||
|
// A handler must never receive a key it did not declare, or an argument the
|
||||||
|
// model made up becomes one a handler might later start reading.
|
||||||
|
var seen Request
|
||||||
|
tool := okTool("thing")
|
||||||
|
tool.Handler = func(_ context.Context, req Request) (Result, error) {
|
||||||
|
seen = req
|
||||||
|
return Result{}, nil
|
||||||
|
}
|
||||||
|
r, _ := registryWith(t, tool)
|
||||||
|
|
||||||
|
args := map[string]any{"limit": 5, "tenantid": 916, "where": "1=1"}
|
||||||
|
if _, err := r.Call(context.Background(), Agent{Name: "a", Tools: []string{"thing"}}, "thing", args, anyone); err != nil {
|
||||||
|
t.Fatalf("calling: %v", err)
|
||||||
|
}
|
||||||
|
if _, present := seen.Args["tenantid"]; present {
|
||||||
|
t.Fatal("the model got to name a tenant")
|
||||||
|
}
|
||||||
|
if _, present := seen.Args["where"]; present {
|
||||||
|
t.Fatal("the model got to pass a where clause")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Registration ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestAToolCannotBeSilentlyReplaced(t *testing.T) {
|
||||||
|
// Overwriting a registered tool is how a permission check disappears.
|
||||||
|
r, _ := registryWith(t, okTool("thing"))
|
||||||
|
if err := r.Register(okTool("thing")); err == nil {
|
||||||
|
t.Fatal("a second tool took the same name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAToolWithoutADescriptionIsRefused(t *testing.T) {
|
||||||
|
// A model chooses between tools by their descriptions. One without is a
|
||||||
|
// tool that gets called for the wrong question.
|
||||||
|
r := New(nil)
|
||||||
|
tool := okTool("thing")
|
||||||
|
tool.Description = ""
|
||||||
|
if err := r.Register(tool); err == nil {
|
||||||
|
t.Fatal("a tool with no description was registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── What the model is told ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestAnAgentIsOnlyToldAboutToolsItMayUse(t *testing.T) {
|
||||||
|
// Describing a tool the agent would then be refused reads to a model as a
|
||||||
|
// malfunction, and to a person as the assistant being broken.
|
||||||
|
r, _ := registryWith(t, okTool("thing"), okTool("other"))
|
||||||
|
defs := r.Definitions(Agent{Name: "orders", Tools: []string{"thing"}})
|
||||||
|
|
||||||
|
if len(defs) != 1 || defs[0]["name"] != "thing" {
|
||||||
|
t.Fatalf("the agent was told about the wrong tools: %v", defs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSchemaForbidsInventedArguments(t *testing.T) {
|
||||||
|
r, _ := registryWith(t, okTool("thing"))
|
||||||
|
defs := r.Definitions(Agent{Name: "orders", Tools: []string{"thing"}})
|
||||||
|
schema, _ := defs[0]["input_schema"].(map[string]any)
|
||||||
|
|
||||||
|
if schema["additionalProperties"] != false {
|
||||||
|
t.Fatal("the schema lets the model add its own arguments")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The audit trail ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestEveryCallLeavesExactlyOneRow(t *testing.T) {
|
||||||
|
r, audit := registryWith(t, okTool("thing"))
|
||||||
|
agent := Agent{Name: "orders", Tools: []string{"thing"}}
|
||||||
|
|
||||||
|
_, _ = r.Call(context.Background(), agent, "thing", nil, anyone)
|
||||||
|
if len(audit.Entries) != 1 {
|
||||||
|
t.Fatalf("a successful call wrote %d rows", len(audit.Entries))
|
||||||
|
}
|
||||||
|
entry, _ := audit.Last()
|
||||||
|
if entry.Outcome != OutcomeOK || entry.Tool != "thing" || entry.Tenantid != 1147 {
|
||||||
|
t.Fatalf("the row does not describe the call: %+v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARefusalIsAudited(t *testing.T) {
|
||||||
|
// The refusals are the interesting ones. A trail of successes answers
|
||||||
|
// "did anything try to read another tenant?" with silence, which reads the
|
||||||
|
// same as "no".
|
||||||
|
r, audit := registryWith(t, okTool("thing"), okTool("other"))
|
||||||
|
|
||||||
|
_, _ = r.Call(context.Background(), Agent{Name: "orders", Tools: []string{"thing"}}, "other", nil, anyone)
|
||||||
|
entry, ok := audit.Last()
|
||||||
|
if !ok || entry.Outcome != OutcomeRefused {
|
||||||
|
t.Fatalf("a refusal left no trace: %+v", entry)
|
||||||
|
}
|
||||||
|
if entry.Detail == "" {
|
||||||
|
t.Fatal("the refusal does not say why")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABrokenToolIsFailedNotRefused(t *testing.T) {
|
||||||
|
// Collapsing the two hides a broken tool inside a count of things working
|
||||||
|
// as designed.
|
||||||
|
tool := okTool("thing")
|
||||||
|
tool.Handler = func(context.Context, Request) (Result, error) {
|
||||||
|
return Result{}, errors.New("the database is down")
|
||||||
|
}
|
||||||
|
r, audit := registryWith(t, tool)
|
||||||
|
|
||||||
|
_, err := r.Call(context.Background(), Agent{Name: "a", Tools: []string{"thing"}}, "thing", nil, anyone)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a broken handler reported success")
|
||||||
|
}
|
||||||
|
entry, _ := audit.Last()
|
||||||
|
if entry.Outcome != OutcomeFailed {
|
||||||
|
t.Fatalf("a handler error was recorded as %q", entry.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheAuditKeepsWhatRanNotWhatWasSent(t *testing.T) {
|
||||||
|
// Defaults applied, invented keys dropped. What actually executed is the
|
||||||
|
// thing worth being able to read back.
|
||||||
|
r, audit := registryWith(t, okTool("thing"))
|
||||||
|
|
||||||
|
_, _ = r.Call(context.Background(), Agent{Name: "a", Tools: []string{"thing"}}, "thing",
|
||||||
|
map[string]any{"tenantid": 916}, anyone)
|
||||||
|
|
||||||
|
entry, _ := audit.Last()
|
||||||
|
if _, present := entry.Args["tenantid"]; present {
|
||||||
|
t.Fatal("the audit kept an argument the handler never saw")
|
||||||
|
}
|
||||||
|
if entry.Args["limit"] != 10 {
|
||||||
|
t.Fatalf("the applied default is missing from the trail: %+v", entry.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAuditLineIsStableBetweenIdenticalCalls(t *testing.T) {
|
||||||
|
// Go randomises map iteration, so without sorting the same call logs
|
||||||
|
// differently every time and a grep for one of them finds one of them.
|
||||||
|
entry := AuditEntry{
|
||||||
|
At: time.Now(), Agent: "orders", Tool: "thing", Userid: 904, Tenantid: 1147,
|
||||||
|
Args: map[string]any{"b": 2, "a": 1, "c": 3}, Outcome: OutcomeOK,
|
||||||
|
}
|
||||||
|
first := entry.Line()
|
||||||
|
for range 20 {
|
||||||
|
if entry.Line() != first {
|
||||||
|
t.Fatalf("two renderings of one entry differ:\n%s\n%s", first, entry.Line())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAuditLineSaysWhenThereWasNoTenant(t *testing.T) {
|
||||||
|
// Explicitly, rather than by omission: no tenant on an assistant call is
|
||||||
|
// either staff or a bug, and both are worth being able to search for.
|
||||||
|
entry := AuditEntry{Agent: "orders", Tool: "thing", Userid: 12, Outcome: OutcomeOK}
|
||||||
|
if got := entry.Line(); !contains(got, "tenant=none") {
|
||||||
|
t.Fatalf("a tenantless call is invisible in the log: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(haystack, needle string) bool {
|
||||||
|
return len(haystack) >= len(needle) && (func() bool {
|
||||||
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||||
|
if haystack[i:i+len(needle)] == needle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})()
|
||||||
|
}
|
||||||
256
services/tools/stuckorders.go
Normal file
256
services/tools/stuckorders.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// "Which orders are stuck?" — the first tool, and a real one.
|
||||||
|
//
|
||||||
|
// A delivery that is still `pending` some time after it was handed out is a job
|
||||||
|
// nobody has picked up. The rider may not have seen it, may have no device, may
|
||||||
|
// have put the phone down. The console cannot tell which, and does not need to:
|
||||||
|
// the wait itself is the fact worth surfacing, and every one of these is a
|
||||||
|
// customer waiting without knowing why.
|
||||||
|
//
|
||||||
|
// ── Derived, never remembered ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Nothing stores "this job went unaccepted". It is computed from `assigntime`
|
||||||
|
// and `orderstatus`, both of which every delivery read already returns, so the
|
||||||
|
// answer does not depend on anybody having been watching when it happened.
|
||||||
|
//
|
||||||
|
// ── Why the two thresholds ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Ten minutes is worth a look; twenty-five needs somebody now. A single
|
||||||
|
// threshold either cries wolf at three minutes — which trains people to ignore
|
||||||
|
// it, and an ignored flag is worse than none — or stays silent until the
|
||||||
|
// customer has already called.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// StuckLookMinutes is when a wait becomes worth a glance.
|
||||||
|
StuckLookMinutes = 10
|
||||||
|
// StuckNowMinutes is when it needs a person.
|
||||||
|
StuckNowMinutes = 25
|
||||||
|
// stuckMaxRows caps one answer. A model handed four hundred rows summarises
|
||||||
|
// them into a sentence nobody can check; a dispatcher can act on ten.
|
||||||
|
stuckMaxRows = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeliveryReader is the one thing this tool needs from the rest of the app.
|
||||||
|
//
|
||||||
|
// Narrowed to a single method so the tool can be tested without a database, and
|
||||||
|
// so it cannot quietly grow a second dependency. The real implementation is
|
||||||
|
// `services.DeliveriesService`.
|
||||||
|
type DeliveryReader interface {
|
||||||
|
GetDeliveries(input models.DeliveryQuery) []models.Deliveryinfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// StuckOrder is one row of the answer.
|
||||||
|
//
|
||||||
|
// Field names are what the model will read back to a person, so they say what
|
||||||
|
// they mean: `WaitingMinutes`, not `delta`.
|
||||||
|
type StuckOrder struct {
|
||||||
|
Deliveryid int `json:"deliveryid"`
|
||||||
|
Orderid string `json:"orderid"`
|
||||||
|
Rider string `json:"rider,omitempty"`
|
||||||
|
Branch string `json:"branch,omitempty"`
|
||||||
|
Customer string `json:"customer,omitempty"`
|
||||||
|
AssignedAt string `json:"assigned_at"`
|
||||||
|
WaitingMinutes int `json:"waiting_minutes"`
|
||||||
|
// "look" or "now" — which bucket the wait falls in. Returned rather than
|
||||||
|
// left to the model to work out from the number, so the threshold is
|
||||||
|
// decided in one place and cannot be re-invented in a sentence.
|
||||||
|
Urgency string `json:"urgency"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StuckOrders builds the tool.
|
||||||
|
//
|
||||||
|
// `now` is injected so the tests can ask what the board looked like at a fixed
|
||||||
|
// instant. Production passes `time.Now`.
|
||||||
|
func StuckOrders(deliveries DeliveryReader, now func() time.Time) Tool {
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tool{
|
||||||
|
Name: "stuck_orders",
|
||||||
|
Description: "Deliveries a rider has been given but has not accepted yet, oldest first. " +
|
||||||
|
"Use for questions about jobs that are stuck, not moving, unaccepted, or riders who have not started. " +
|
||||||
|
"Returns the wait in minutes and what to do about each one.",
|
||||||
|
Scope: ScopeRead,
|
||||||
|
Schema: Schema{Fields: []Field{{
|
||||||
|
Name: "minutes_waiting",
|
||||||
|
Description: "Only count jobs unaccepted for at least this many minutes. Defaults to 10.",
|
||||||
|
Kind: KindInt,
|
||||||
|
Min: 1,
|
||||||
|
Max: 720,
|
||||||
|
Default: StuckLookMinutes,
|
||||||
|
}}},
|
||||||
|
Handler: func(_ context.Context, req Request) (Result, error) {
|
||||||
|
// The tenant comes from the verified session, never from an
|
||||||
|
// argument. There is deliberately no `tenantid` field on the schema
|
||||||
|
// above: a tool that accepted one would let the model be talked into
|
||||||
|
// reading somebody else's shop, and the model is the one part of
|
||||||
|
// this system that can be argued with.
|
||||||
|
if req.Caller.Tenantid <= 0 {
|
||||||
|
return Result{}, fmt.Errorf("stuck_orders needs a tenant; staff must pick one first")
|
||||||
|
}
|
||||||
|
|
||||||
|
threshold := req.Int("minutes_waiting")
|
||||||
|
if threshold <= 0 {
|
||||||
|
threshold = StuckLookMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := deliveries.GetDeliveries(models.DeliveryQuery{
|
||||||
|
Tenantid: req.Caller.Tenantid,
|
||||||
|
// The caller's own branch when they have one. A branch user asking
|
||||||
|
// "what is stuck?" means their shop; an admin with no home branch
|
||||||
|
// means all of them.
|
||||||
|
Locationid: req.Caller.Locationid,
|
||||||
|
Pagesize: 500,
|
||||||
|
Pageno: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
at := now()
|
||||||
|
stuck := make([]StuckOrder, 0, 8)
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
waited, ok := unacceptedFor(row, at)
|
||||||
|
if !ok || waited < time.Duration(threshold)*time.Minute {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
minutes := int(waited.Minutes())
|
||||||
|
|
||||||
|
urgency, action := "look", "Check the rider has seen it."
|
||||||
|
if minutes >= StuckNowMinutes {
|
||||||
|
urgency, action = "now", "Call the rider, or give the job to somebody else."
|
||||||
|
}
|
||||||
|
|
||||||
|
stuck = append(stuck, StuckOrder{
|
||||||
|
Deliveryid: row.Deliveryid,
|
||||||
|
Orderid: row.Orderid,
|
||||||
|
// `ridername` is not reliably a name — on tenant 916 every
|
||||||
|
// rider has delivery statuses in that column too, and for two
|
||||||
|
// of five the status is the MORE common value. Excluded by
|
||||||
|
// vocabulary rather than by a hand-written list.
|
||||||
|
Rider: riderName(row.Ridername),
|
||||||
|
Branch: row.Locationname,
|
||||||
|
Customer: row.Deliverycustomer,
|
||||||
|
AssignedAt: row.Assigntime,
|
||||||
|
WaitingMinutes: minutes,
|
||||||
|
Urgency: urgency,
|
||||||
|
Action: action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worst first, then longest waiting. This is a worklist, not a log:
|
||||||
|
// the row to deal with next belongs at the top.
|
||||||
|
sort.SliceStable(stuck, func(i, j int) bool {
|
||||||
|
if stuck[i].Urgency != stuck[j].Urgency {
|
||||||
|
return stuck[i].Urgency == "now"
|
||||||
|
}
|
||||||
|
return stuck[i].WaitingMinutes > stuck[j].WaitingMinutes
|
||||||
|
})
|
||||||
|
|
||||||
|
result := Result{
|
||||||
|
Count: len(stuck),
|
||||||
|
Source: "/admin/dispatch",
|
||||||
|
Scope: scopeWords(req.Caller),
|
||||||
|
}
|
||||||
|
if len(stuck) > stuckMaxRows {
|
||||||
|
result.Truncated = true
|
||||||
|
result.Note = fmt.Sprintf(
|
||||||
|
"%d jobs are waiting; the %d longest are listed. Say so — do not describe this as the full list.",
|
||||||
|
len(stuck), stuckMaxRows)
|
||||||
|
stuck = stuck[:stuckMaxRows]
|
||||||
|
}
|
||||||
|
result.Rows = stuck
|
||||||
|
return result, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unacceptedFor is how long a job has sat with nobody accepting it.
|
||||||
|
//
|
||||||
|
// Only `pending` counts. A job the rider accepted, picked up or delivered is
|
||||||
|
// not stuck however old it is, and `rejected` or `skipped` is a different
|
||||||
|
// problem with a different answer — those are somebody's to reassign, not to
|
||||||
|
// chase.
|
||||||
|
//
|
||||||
|
// A stamp that will not parse returns false rather than 1970. Reading an
|
||||||
|
// unparseable `assigntime` as the epoch would report every such row as fifty
|
||||||
|
// years late, which is the kind of number that gets a whole screen ignored.
|
||||||
|
func unacceptedFor(row models.Deliveryinfo, now time.Time) (time.Duration, bool) {
|
||||||
|
if strings.ToLower(strings.TrimSpace(row.Orderstatus)) != "pending" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
assigned, ok := parseStamp(row.Assigntime)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
waited := now.Sub(assigned)
|
||||||
|
if waited < 0 {
|
||||||
|
// A clock ahead of ours, not a job from the future.
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return waited, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStamp reads `assigntime` as the writer actually writes it.
|
||||||
|
//
|
||||||
|
// Local wall-clock with no zone — `2026-09-23 14:05:31` — which is what
|
||||||
|
// `stampNow` produces. Parsed in the server's own location rather than UTC,
|
||||||
|
// because reading local digits as UTC would put every job five and a half hours
|
||||||
|
// out in India and turn a fresh assignment into a four-hour wait.
|
||||||
|
func parseStamp(raw string) (time.Time, bool) {
|
||||||
|
text := strings.TrimSpace(raw)
|
||||||
|
if text == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
for _, layout := range []string{
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
time.RFC3339,
|
||||||
|
} {
|
||||||
|
if at, err := time.ParseInLocation(layout, text, time.Local); err == nil {
|
||||||
|
return at, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// riderName keeps a name and drops a status wearing one.
|
||||||
|
//
|
||||||
|
// `deliveries.ridername` carries both. Measured on tenant 916: rider 897 has
|
||||||
|
// "Varun" 69 times and "delivered" 75, so neither "first non-empty" nor "most
|
||||||
|
// common" finds the name. The statuses are excluded by vocabulary, so a status
|
||||||
|
// added to the ladder is excluded the same day.
|
||||||
|
func riderName(raw string) string {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, status := range []string{
|
||||||
|
"pending", "accepted", "arrived", "picked", "active",
|
||||||
|
"skipped", "rejected", "delivered", "cancelled", "waiting",
|
||||||
|
} {
|
||||||
|
if name == status {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeWords says what the answer covers, in words a person would use.
|
||||||
|
func scopeWords(caller Caller) string {
|
||||||
|
if caller.Locationid > 0 {
|
||||||
|
return "this branch"
|
||||||
|
}
|
||||||
|
return "all branches"
|
||||||
|
}
|
||||||
305
services/tools/stuckorders_test.go
Normal file
305
services/tools/stuckorders_test.go
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var stuckNow = time.Date(2026, 9, 23, 14, 0, 0, 0, time.Local)
|
||||||
|
|
||||||
|
// fakeDeliveries stands in for the deliveries service, and records what it was
|
||||||
|
// asked — the query matters as much as the answer, because that is where the
|
||||||
|
// tenant scope either is or is not.
|
||||||
|
type fakeDeliveries struct {
|
||||||
|
rows []models.Deliveryinfo
|
||||||
|
last models.DeliveryQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDeliveries) GetDeliveries(input models.DeliveryQuery) []models.Deliveryinfo {
|
||||||
|
f.last = input
|
||||||
|
return f.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignedMinutesAgo writes the stamp the way `stampNow` does: local
|
||||||
|
// wall-clock, no zone.
|
||||||
|
func assignedMinutesAgo(n int) string {
|
||||||
|
return stuckNow.Add(-time.Duration(n) * time.Minute).Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
func job(over models.Deliveryinfo) models.Deliveryinfo {
|
||||||
|
if over.Orderstatus == "" {
|
||||||
|
over.Orderstatus = "pending"
|
||||||
|
}
|
||||||
|
if over.Assigntime == "" {
|
||||||
|
over.Assigntime = assignedMinutesAgo(30)
|
||||||
|
}
|
||||||
|
return over
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStuck(t *testing.T, rows []models.Deliveryinfo, args map[string]any, caller Caller) (Result, *fakeDeliveries) {
|
||||||
|
t.Helper()
|
||||||
|
deliveries := &fakeDeliveries{rows: rows}
|
||||||
|
r := New(nil)
|
||||||
|
if err := r.Register(StuckOrders(deliveries, func() time.Time { return stuckNow })); err != nil {
|
||||||
|
t.Fatalf("registering: %v", err)
|
||||||
|
}
|
||||||
|
result, err := r.Call(context.Background(), Agent{Name: "orders", Tools: []string{"stuck_orders"}}, "stuck_orders", args, caller)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("calling: %v", err)
|
||||||
|
}
|
||||||
|
return result, deliveries
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowsOf(t *testing.T, result Result) []StuckOrder {
|
||||||
|
t.Helper()
|
||||||
|
rows, ok := result.Rows.([]StuckOrder)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("rows are not stuck orders: %T", result.Rows)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The tenant is never the model's to choose ─────────────────────────── */
|
||||||
|
|
||||||
|
func TestTheTenantComesFromTheSessionNotTheArguments(t *testing.T) {
|
||||||
|
// The single most important property of the whole registry. The model picks
|
||||||
|
// the tool and the arguments; it has no say in whose data is read.
|
||||||
|
_, deliveries := runStuck(t, nil, map[string]any{"tenantid": 916}, Caller{Userid: 904, Tenantid: 1147})
|
||||||
|
|
||||||
|
if deliveries.last.Tenantid != 1147 {
|
||||||
|
t.Fatalf("the query ran against tenant %d", deliveries.last.Tenantid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheToolDoesNotEvenAcceptATenantArgument(t *testing.T) {
|
||||||
|
// Belt and braces: the schema must not have the field at all, so there is
|
||||||
|
// nothing to argue the model into filling in.
|
||||||
|
tool := StuckOrders(&fakeDeliveries{}, nil)
|
||||||
|
for _, field := range tool.Schema.Fields {
|
||||||
|
if field.Name == "tenantid" || field.Name == "locationid" {
|
||||||
|
t.Fatalf("the schema offers %q for the model to set", field.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABranchUserIsScopedToTheirBranch(t *testing.T) {
|
||||||
|
_, deliveries := runStuck(t, nil, nil, Caller{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
||||||
|
if deliveries.last.Locationid != 1172 {
|
||||||
|
t.Fatalf("a branch user read branch %d", deliveries.last.Locationid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaffMustPickATenantFirst(t *testing.T) {
|
||||||
|
// A platform account passes the registry's caller check but cannot ask this
|
||||||
|
// question of "everyone" — the answer would span merchants.
|
||||||
|
deliveries := &fakeDeliveries{}
|
||||||
|
r := New(nil)
|
||||||
|
_ = r.Register(StuckOrders(deliveries, func() time.Time { return stuckNow }))
|
||||||
|
|
||||||
|
_, err := r.Call(context.Background(), Agent{Name: "orders", Tools: []string{"stuck_orders"}},
|
||||||
|
"stuck_orders", nil, Caller{Userid: 12, Superadmin: true})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("staff read stuck orders across every tenant at once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── What counts as stuck ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestOnlyPendingJobsAreStuck(t *testing.T) {
|
||||||
|
// A job the rider accepted, picked up or delivered is not stuck however old
|
||||||
|
// it is. Rejected and skipped are a different problem with a different fix.
|
||||||
|
rows := []models.Deliveryinfo{
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 1, Orderstatus: "pending"}),
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 2, Orderstatus: "accepted"}),
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 3, Orderstatus: "picked"}),
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 4, Orderstatus: "delivered"}),
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 5, Orderstatus: "rejected"}),
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 6, Orderstatus: "skipped"}),
|
||||||
|
}
|
||||||
|
result, _ := runStuck(t, rows, nil, anyone)
|
||||||
|
|
||||||
|
if result.Count != 1 || rowsOf(t, result)[0].Deliveryid != 1 {
|
||||||
|
t.Fatalf("expected only the pending job: %+v", result.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheCasingTheRiderAppActuallyWrites(t *testing.T) {
|
||||||
|
// Fiesta stores status as free text and the casing varies between writers.
|
||||||
|
rows := []models.Deliveryinfo{job(models.Deliveryinfo{Deliveryid: 1, Orderstatus: "Pending"})}
|
||||||
|
result, _ := runStuck(t, rows, nil, anyone)
|
||||||
|
if result.Count != 1 {
|
||||||
|
t.Fatal("a capitalised status stopped counting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFewMinutesOfSilenceIsOrdinary(t *testing.T) {
|
||||||
|
// Flagging this would train people to ignore the flag, and an ignored flag
|
||||||
|
// is worse than none.
|
||||||
|
rows := []models.Deliveryinfo{
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 1, Assigntime: assignedMinutesAgo(3)}),
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 2, Assigntime: assignedMinutesAgo(9)}),
|
||||||
|
}
|
||||||
|
result, _ := runStuck(t, rows, nil, anyone)
|
||||||
|
if result.Count != 0 {
|
||||||
|
t.Fatalf("a nine-minute wait was reported as stuck: %+v", result.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenMinutesIsALookAndTwentyFiveNeedsSomebody(t *testing.T) {
|
||||||
|
rows := []models.Deliveryinfo{
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 1, Assigntime: assignedMinutesAgo(12)}),
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 2, Assigntime: assignedMinutesAgo(40)}),
|
||||||
|
}
|
||||||
|
result, _ := runStuck(t, rows, nil, anyone)
|
||||||
|
got := rowsOf(t, result)
|
||||||
|
|
||||||
|
// Worst first: the forty-minute job leads.
|
||||||
|
if got[0].Deliveryid != 2 || got[0].Urgency != "now" {
|
||||||
|
t.Fatalf("the urgent job is not first: %+v", got)
|
||||||
|
}
|
||||||
|
if got[1].Urgency != "look" {
|
||||||
|
t.Fatalf("a twelve-minute wait was not a look: %+v", got[1])
|
||||||
|
}
|
||||||
|
if got[0].Action == got[1].Action {
|
||||||
|
t.Fatal("both buckets suggest the same action, so the bucket says nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheThresholdCanBeRaised(t *testing.T) {
|
||||||
|
rows := []models.Deliveryinfo{
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 1, Assigntime: assignedMinutesAgo(12)}),
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 2, Assigntime: assignedMinutesAgo(40)}),
|
||||||
|
}
|
||||||
|
result, _ := runStuck(t, rows, map[string]any{"minutes_waiting": 30}, anyone)
|
||||||
|
if result.Count != 1 || rowsOf(t, result)[0].Deliveryid != 2 {
|
||||||
|
t.Fatalf("the threshold was ignored: %+v", result.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheWaitIsReportedInMinutes(t *testing.T) {
|
||||||
|
rows := []models.Deliveryinfo{job(models.Deliveryinfo{Deliveryid: 1, Assigntime: assignedMinutesAgo(40)})}
|
||||||
|
result, _ := runStuck(t, rows, nil, anyone)
|
||||||
|
if got := rowsOf(t, result)[0].WaitingMinutes; got != 40 {
|
||||||
|
t.Fatalf("waiting minutes reported as %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stamps that cannot be read ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestAnUnreadableAssigntimeIsSkippedNotDatedTo1970(t *testing.T) {
|
||||||
|
// Reading it as the epoch would report the row as fifty years late, which
|
||||||
|
// is the kind of number that gets a whole screen ignored.
|
||||||
|
// Built without `job()`, which fills a default stamp in — the helper would
|
||||||
|
// hand the parser a valid date and the test would pass without testing.
|
||||||
|
rows := []models.Deliveryinfo{
|
||||||
|
{Deliveryid: 1, Orderstatus: "pending", Assigntime: ""},
|
||||||
|
{Deliveryid: 2, Orderstatus: "pending", Assigntime: "not a date"},
|
||||||
|
{Deliveryid: 3, Orderstatus: "pending", Assigntime: " "},
|
||||||
|
}
|
||||||
|
result, _ := runStuck(t, rows, nil, anyone)
|
||||||
|
if result.Count != 0 {
|
||||||
|
t.Fatalf("an unreadable stamp produced a row: %+v", result.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestALocalStampIsNotReadAsUTC(t *testing.T) {
|
||||||
|
// `assigntime` is local wall-clock with no zone. Read as UTC it would be
|
||||||
|
// five and a half hours out in India, turning a fresh assignment into a
|
||||||
|
// four-hour wait.
|
||||||
|
rows := []models.Deliveryinfo{job(models.Deliveryinfo{Deliveryid: 1, Assigntime: assignedMinutesAgo(30)})}
|
||||||
|
result, _ := runStuck(t, rows, nil, anyone)
|
||||||
|
if got := rowsOf(t, result)[0].WaitingMinutes; got != 30 {
|
||||||
|
t.Fatalf("a local stamp read as %d minutes instead of 30", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAJobFromTheFutureIsAClockNotAWait(t *testing.T) {
|
||||||
|
rows := []models.Deliveryinfo{job(models.Deliveryinfo{Deliveryid: 1, Assigntime: assignedMinutesAgo(-20)})}
|
||||||
|
result, _ := runStuck(t, rows, nil, anyone)
|
||||||
|
if result.Count != 0 {
|
||||||
|
t.Fatalf("a stamp in the future was reported as a wait: %+v", result.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The rider's name ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestAStatusInTheRiderColumnIsNotAName(t *testing.T) {
|
||||||
|
// Measured on tenant 916: rider 897 carries "Varun" 69 times and
|
||||||
|
// "delivered" 75, so neither "first non-empty" nor "most common" finds the
|
||||||
|
// name. A model handed "delivered" would tell somebody to call a rider
|
||||||
|
// called Delivered.
|
||||||
|
rows := []models.Deliveryinfo{
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 1, Ridername: "delivered"}),
|
||||||
|
job(models.Deliveryinfo{Deliveryid: 2, Ridername: "Varun"}),
|
||||||
|
}
|
||||||
|
result, _ := runStuck(t, rows, nil, anyone)
|
||||||
|
got := rowsOf(t, result)
|
||||||
|
|
||||||
|
byID := map[int]string{}
|
||||||
|
for _, row := range got {
|
||||||
|
byID[row.Deliveryid] = row.Rider
|
||||||
|
}
|
||||||
|
if byID[1] != "" {
|
||||||
|
t.Fatalf("a status was reported as a rider: %q", byID[1])
|
||||||
|
}
|
||||||
|
if byID[2] != "Varun" {
|
||||||
|
t.Fatalf("a real name was dropped: %q", byID[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Saying when the answer is partial ─────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestACappedAnswerSaysSo(t *testing.T) {
|
||||||
|
// An empty answer and a capped answer look identical to a model, and it
|
||||||
|
// will describe both as "none".
|
||||||
|
rows := make([]models.Deliveryinfo, 0, 60)
|
||||||
|
for i := range 60 {
|
||||||
|
rows = append(rows, job(models.Deliveryinfo{Deliveryid: i + 1, Assigntime: assignedMinutesAgo(30 + i)}))
|
||||||
|
}
|
||||||
|
result, _ := runStuck(t, rows, nil, anyone)
|
||||||
|
|
||||||
|
if !result.Truncated {
|
||||||
|
t.Fatal("sixty rows came back as a complete answer")
|
||||||
|
}
|
||||||
|
if result.Note == "" {
|
||||||
|
t.Fatal("the cap is not explained in words the model will repeat")
|
||||||
|
}
|
||||||
|
if len(rowsOf(t, result)) != stuckMaxRows {
|
||||||
|
t.Fatalf("the cap did not apply: %d rows", len(rowsOf(t, result)))
|
||||||
|
}
|
||||||
|
if result.Count != 60 {
|
||||||
|
t.Fatalf("the true total was lost: %d", result.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnEmptyBoardIsAnAnswer(t *testing.T) {
|
||||||
|
result, _ := runStuck(t, nil, nil, anyone)
|
||||||
|
if result.Count != 0 || result.Truncated {
|
||||||
|
t.Fatalf("an empty board was not answered cleanly: %+v", result)
|
||||||
|
}
|
||||||
|
if result.Scope == "" {
|
||||||
|
t.Fatal("even an empty answer must say what it covered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Evidence ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestTheAnswerCarriesWhereToCheckIt(t *testing.T) {
|
||||||
|
// Buddy states a conclusion; this is where a person goes to see the rows.
|
||||||
|
result, _ := runStuck(t, []models.Deliveryinfo{job(models.Deliveryinfo{Deliveryid: 1})}, nil, anyone)
|
||||||
|
if result.Source == "" {
|
||||||
|
t.Fatal("the answer links to nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheScopeIsStatedSoOneBranchIsNotReadAsAll(t *testing.T) {
|
||||||
|
all, _ := runStuck(t, nil, nil, Caller{Userid: 904, Tenantid: 1147})
|
||||||
|
one, _ := runStuck(t, nil, nil, Caller{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
||||||
|
|
||||||
|
if all.Scope == one.Scope {
|
||||||
|
t.Fatalf("one branch and all branches report the same scope: %q", all.Scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
180
utils/webtoken.go
Normal file
180
utils/webtoken.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session tokens for the console.
|
||||||
|
//
|
||||||
|
// The same construction as the POS token next door — `base64url(payload).
|
||||||
|
// base64url(hmac-sha256)`, signed with the same key, deliberately not JWT —
|
||||||
|
// and for the same reasons, which `postoken.go` sets out in full. What differs
|
||||||
|
// is who is carrying it and what it is allowed to say.
|
||||||
|
//
|
||||||
|
// ── Why the console needs one at all ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// It has never had one. The console keeps its login record in `sessionStorage`
|
||||||
|
// and sends no `Authorization` header, so every `/web` endpoint has been taking
|
||||||
|
// `tenantid` off the query string and believing it. That is the same hole
|
||||||
|
// `middleware/posauth.go` was written to close on the POS surface — its own
|
||||||
|
// header describes a till naming another shop's id in a URL and being trusted —
|
||||||
|
// except that on the web surface nothing has closed it yet.
|
||||||
|
//
|
||||||
|
// ── A browser is not a till ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The POS token lasts thirty days because a shop signs a terminal in once and
|
||||||
|
// expects it to keep billing through reboots and dead networks. A browser tab
|
||||||
|
// is the opposite: the console already drops its session when the tab closes,
|
||||||
|
// because `sessionStorage` is per-tab by design. So the expiry here is a
|
||||||
|
// backstop for a tab left open, not the thing that ends the session, and a
|
||||||
|
// working day is the right order of magnitude.
|
||||||
|
//
|
||||||
|
// ── What the claims may say ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `Tenantid` is the load-bearing field, as `Locationid` is for POS. It is taken
|
||||||
|
// from the user's own record at sign-in and sealed under the signature, so a
|
||||||
|
// request can no longer name whichever tenant it likes.
|
||||||
|
//
|
||||||
|
// Platform access — Nearle's own staff, who work across every tenant and
|
||||||
|
// legitimately need to — rides on `Superadmin`, and NOT on the tenant being
|
||||||
|
// zero, nor on any role id.
|
||||||
|
//
|
||||||
|
// Both of those shortcuts are wrong, and the console learned it the hard way.
|
||||||
|
// `app_roles` calls roleid 1 "Super admin" and tenant onboarding wrote 1 for
|
||||||
|
// every shop owner, so a role test hands platform access to every merchant on
|
||||||
|
// the system. And a `Tenantid == 0` test promotes any user row whose tenant was
|
||||||
|
// never filled in — a missing field becoming the one session that reads
|
||||||
|
// everything. The real signal is `app_users.issuperadmin`, a column somebody
|
||||||
|
// has to deliberately set.
|
||||||
|
type WebClaims struct {
|
||||||
|
Userid int `json:"uid"`
|
||||||
|
// The tenant this session is pinned to. Every read and write stays inside
|
||||||
|
// it unless Superadmin says otherwise.
|
||||||
|
Tenantid int `json:"tid"`
|
||||||
|
// Nearle staff, from `app_users.issuperadmin`. The only thing that lifts
|
||||||
|
// the tenant pin; see above for the two tests that look equivalent and are
|
||||||
|
// not.
|
||||||
|
Superadmin bool `json:"sa,omitempty"`
|
||||||
|
// The user's home branch, where they have one. Not a restriction on its
|
||||||
|
// own: a tenant admin with six shops reads all six, and the check that
|
||||||
|
// decides which is `LocationAllowed` against the tenant, not this field.
|
||||||
|
Locationid int `json:"lid,omitempty"`
|
||||||
|
Roleid int `json:"rid"`
|
||||||
|
Configid int `json:"cid,omitempty"`
|
||||||
|
Issuedat int64 `json:"iat"`
|
||||||
|
Expiresat int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebTokenTTL is how long a console session stays valid.
|
||||||
|
//
|
||||||
|
// Twelve hours: longer than a shift, shorter than a week. The tab closing is
|
||||||
|
// what normally ends the session, so this only decides how long a tab left open
|
||||||
|
// overnight keeps working — and a person coming back the next morning signing
|
||||||
|
// in again is a reasonable thing to ask, where the same demand of a till
|
||||||
|
// mid-trade is not.
|
||||||
|
const WebTokenTTL = 12 * time.Hour
|
||||||
|
|
||||||
|
// IsPlatformAccount reports whether these claims may read across tenants.
|
||||||
|
//
|
||||||
|
// One function rather than `claims.Tenantid == 0` written out at each call
|
||||||
|
// site, so the rule can be found, tested, and changed in one place. Every
|
||||||
|
// cross-tenant decision in the middleware goes through it.
|
||||||
|
func (c WebClaims) IsPlatformAccount() bool { return c.Superadmin }
|
||||||
|
|
||||||
|
// MintWebToken issues a session for a signed-in console user.
|
||||||
|
//
|
||||||
|
// Shares `posTokenSecret` with the POS token: one signing key for the
|
||||||
|
// deployment, one place it can be missing, one error when it is. A second
|
||||||
|
// variable would be a second thing to forget.
|
||||||
|
func MintWebToken(claims WebClaims, now time.Time) (string, time.Time, error) {
|
||||||
|
secret, err := posTokenSecret()
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
expires := now.Add(WebTokenTTL)
|
||||||
|
claims.Issuedat = now.Unix()
|
||||||
|
claims.Expiresat = expires.Unix()
|
||||||
|
|
||||||
|
payload, err := json.Marshal(claims)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
||||||
|
return webTokenPrefix + encoded + "." + sign(encoded, secret), expires, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webTokenPrefix keeps the two token kinds apart on the wire.
|
||||||
|
//
|
||||||
|
// Without it a POS token and a console token are the same shape signed with the
|
||||||
|
// same key, so one would verify as the other and a cashier's token would parse
|
||||||
|
// into web claims with `Locationid` in the seat `Tenantid` should occupy. The
|
||||||
|
// prefix is checked before the signature and is the reason `ParseWebToken`
|
||||||
|
// cannot accept a till's session.
|
||||||
|
const webTokenPrefix = "w1."
|
||||||
|
|
||||||
|
// ParseWebToken verifies a console token and returns what it claims.
|
||||||
|
//
|
||||||
|
// The order is the same as the POS parser's and matters for the same reason:
|
||||||
|
// nothing in the payload is trusted — not the expiry, not the tenant — until
|
||||||
|
// the signature has been checked. Reading `exp` from an unverified payload is
|
||||||
|
// taking the caller's word for when their own token runs out.
|
||||||
|
func ParseWebToken(token string, now time.Time) (WebClaims, error) {
|
||||||
|
secret, err := posTokenSecret()
|
||||||
|
if err != nil {
|
||||||
|
return WebClaims{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := strings.TrimSpace(token)
|
||||||
|
after, found := strings.CutPrefix(raw, webTokenPrefix)
|
||||||
|
if !found {
|
||||||
|
return WebClaims{}, fmt.Errorf("not a console session token")
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, signature, found := strings.Cut(after, ".")
|
||||||
|
if !found || encoded == "" || signature == "" {
|
||||||
|
return WebClaims{}, fmt.Errorf("malformed session token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant time, so the right signature cannot be learned a byte at a time
|
||||||
|
// from how long the comparison took.
|
||||||
|
if !hmac.Equal([]byte(signature), []byte(sign(encoded, secret))) {
|
||||||
|
return WebClaims{}, fmt.Errorf("session token signature does not verify")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return WebClaims{}, fmt.Errorf("malformed session token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims WebClaims
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return WebClaims{}, fmt.Errorf("malformed session token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Expiresat > 0 && now.Unix() >= claims.Expiresat {
|
||||||
|
return WebClaims{}, fmt.Errorf("session has expired; sign in again")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token naming nobody authorises nothing, and must not be mistaken for one
|
||||||
|
// authorising everything.
|
||||||
|
if claims.Userid <= 0 {
|
||||||
|
return WebClaims{}, fmt.Errorf("session token names no user")
|
||||||
|
}
|
||||||
|
// A tenant session must name its tenant. Staff are the only accounts that
|
||||||
|
// may carry none, and they have to say so explicitly.
|
||||||
|
if claims.Tenantid <= 0 && !claims.Superadmin {
|
||||||
|
return WebClaims{}, fmt.Errorf("session token names no tenant")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebTokenConfigured reports whether console sessions can be issued at all.
|
||||||
|
func WebTokenConfigured() bool { return PosTokenConfigured() }
|
||||||
216
utils/webtoken_test.go
Normal file
216
utils/webtoken_test.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAConsoleSessionSurvivesTheRoundTrip(t *testing.T) {
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
token, expires, err := MintWebToken(WebClaims{
|
||||||
|
Userid: 904, Tenantid: 1147, Locationid: 1172, Roleid: 3, Configid: 2,
|
||||||
|
}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := ParseWebToken(token, now.Add(time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsing a token we just issued: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Tenantid != 1147 || claims.Userid != 904 {
|
||||||
|
t.Fatalf("the identity did not survive: user %d tenant %d", claims.Userid, claims.Tenantid)
|
||||||
|
}
|
||||||
|
if claims.Locationid != 1172 || claims.Roleid != 3 {
|
||||||
|
t.Fatalf("branch or role lost: location %d role %d", claims.Locationid, claims.Roleid)
|
||||||
|
}
|
||||||
|
if !expires.After(now) {
|
||||||
|
t.Fatalf("expiry is not in the future: %v", expires)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The two token kinds must not be interchangeable ────────────────────── */
|
||||||
|
|
||||||
|
func TestATillsTokenIsNotAConsoleSession(t *testing.T) {
|
||||||
|
// The whole reason `webTokenPrefix` exists. Both tokens are the same shape
|
||||||
|
// signed with the same key, so without the prefix a POS token verifies as a
|
||||||
|
// web one — and its `Locationid` would land where `Tenantid` is read, which
|
||||||
|
// is the field every permission decision is made on.
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
pos, _, err := MintPosToken(PosClaims{Userid: 7, Tenantid: 916, Locationid: 1185, Roleid: 8}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting a POS token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ParseWebToken(pos, now); err == nil {
|
||||||
|
t.Fatal("a cashier's token was accepted as a console session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAConsoleSessionIsNotATillsToken(t *testing.T) {
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
web, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ParsePosToken(web, now); err == nil {
|
||||||
|
t.Fatal("a console session was accepted at the till")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Forgery and tampering ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestATamperedTenantDoesNotVerify(t *testing.T) {
|
||||||
|
// The attack this is all for: take a valid session, change the tenant, read
|
||||||
|
// somebody else's shop.
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
token, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, signature, _ := strings.Cut(strings.TrimPrefix(token, webTokenPrefix), ".")
|
||||||
|
forged := webTokenPrefix + body[:len(body)-1] + "X" + "." + signature
|
||||||
|
|
||||||
|
if _, err := ParseWebToken(forged, now); err == nil {
|
||||||
|
t.Fatal("an edited payload verified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATokenSignedWithAnotherKeyIsRefused(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
withSecret(t, "a-completely-different-signing-key")
|
||||||
|
token, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
if _, err := ParseWebToken(token, now); err == nil {
|
||||||
|
t.Fatal("a token signed with someone else's key verified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoSigningKeyMeansNoSessions(t *testing.T) {
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", "")
|
||||||
|
t.Setenv("JWT_SECRET_KEY", "")
|
||||||
|
|
||||||
|
if _, _, err := MintWebToken(WebClaims{Userid: 1, Tenantid: 1}, time.Now()); err == nil {
|
||||||
|
t.Fatal("a session was issued with no signing key")
|
||||||
|
}
|
||||||
|
if WebTokenConfigured() {
|
||||||
|
t.Fatal("reported configured with no signing key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Expiry ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestASessionExpires(t *testing.T) {
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
token, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ParseWebToken(token, now.Add(WebTokenTTL-time.Minute)); err != nil {
|
||||||
|
t.Fatalf("refused inside its life: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ParseWebToken(token, now.Add(WebTokenTTL+time.Minute)); err == nil {
|
||||||
|
t.Fatal("a tab left open overnight still authorised")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The platform account ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestPlatformAccessComesFromSuperadminAndNothingElse(t *testing.T) {
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
token, _, err := MintWebToken(WebClaims{Userid: 12, Superadmin: true, Roleid: 1}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
claims, err := ParseWebToken(token, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a staff session was refused: %v", err)
|
||||||
|
}
|
||||||
|
if !claims.IsPlatformAccount() {
|
||||||
|
t.Fatal("issuperadmin did not grant platform access")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleid1IsAMerchantNotAPlatformOperator(t *testing.T) {
|
||||||
|
// `app_roles` calls roleid 1 "Super admin", and tenant onboarding wrote 1
|
||||||
|
// for every shop owner. A role test here would hand cross-tenant access to
|
||||||
|
// every merchant on the system — the console already had to fix this once.
|
||||||
|
claims := WebClaims{Userid: 904, Tenantid: 1147, Roleid: 1}
|
||||||
|
if claims.IsPlatformAccount() {
|
||||||
|
t.Fatal("roleid 1 claimed platform access")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMissingTenantIsNotAPlatformAccount(t *testing.T) {
|
||||||
|
// The other near-miss: a user row whose tenant was never filled in must not
|
||||||
|
// become the one session that reads everything. Go's zero value is 0, so
|
||||||
|
// this is exactly what a forgotten field looks like.
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
claims := WebClaims{Userid: 904, Tenantid: 0}
|
||||||
|
if claims.IsPlatformAccount() {
|
||||||
|
t.Fatal("a missing tenant claimed platform access")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, _, err := MintWebToken(claims, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ParseWebToken(token, now); err == nil {
|
||||||
|
t.Fatal("a session naming no tenant and claiming no staff status verified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATokenNamingNobodyIsRefused(t *testing.T) {
|
||||||
|
// A token that authorises nothing must not be mistaken for one that
|
||||||
|
// authorises everything.
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
token, _, err := MintWebToken(WebClaims{Userid: 0, Tenantid: 1147}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ParseWebToken(token, now); err == nil {
|
||||||
|
t.Fatal("a token naming no user verified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shape ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestMalformedTokensAreRefusedWithoutPanicking(t *testing.T) {
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for _, token := range []string{
|
||||||
|
"", " ", "w1.", "w1..", "w1.onlyonepart",
|
||||||
|
"w1.!!!not-base64!!!.sig", "no-prefix.payload.sig",
|
||||||
|
} {
|
||||||
|
if _, err := ParseWebToken(token, now); err == nil {
|
||||||
|
t.Fatalf("accepted a malformed token: %q", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user