2 Commits

Author SHA1 Message Date
4db71e9381 A wrong URL answered 500, and only the real database said so
/api/admin/clients/not-a-uuid/sites returned 500. `c.id = $1::uuid` makes
Postgres cast the path segment, and casting a malformed string - or the
empty one the shape check handed back in its place - is an ERROR, not a
miss. `c.id::text = $1` cannot fail: an id that is not a uuid matches
nothing, which is the 404 a wrong URL should get.

The two sibling resolvers were already written this way and correctly
404'd the same input. I applied the rule to two of three places, which is
the shape of a rule that holds until somebody adds the next write path.
The shape check is gone with it - it existed only to produce the empty
string that then broke the cast.

The in-memory fake could not have caught this and did not: it resolves a
merchant with a map lookup, so every handler test passed, including the
one named for the case. That test stays, because 404-not-500 is still the
contract, but the property belongs to Postgres - so
api_admin_monitor_live_test.go asserts it where it lives, over every
free-text identifier these queries take. It skips without
TEST_DATABASE_URL, like the rest of the live store tests.

Also in deploy.sh, found by reading its own output: step 3 reported the
WRONG backup. `ls | tail -1` sorts alphabetically, so pre-...-demo-12
sorts before pre-...-demo-6 and it printed a dump from four days earlier.
A deploy that names the wrong safety net is worse than one that names
none, because that is the file somebody reaches for at the worst possible
moment. It echoes the filename it just wrote, and refuses to continue on
an empty one - pipefail catches a failing pg_dump, but a zero-byte gzip
would still have satisfied it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 19:14:26 +05:30
abcf6aa012 The admin console could list merchants and see nothing inside them
Six read-only routes: merchant detail, its shops, one shop, its cameras,
one camera, and the platform totals. The console drills down
merchant -> store -> camera and every level below the first showed
'Backend integration required'.

They cannot be the tenant routes, and the reason is structural rather
than incidental. Every tenant handler derives the client from the
SESSION - that is what makes cross-tenant access impossible rather than
merely disallowed - and a platform admin has no client at all. The three
workarounds each make it worse: passing a company id to a tenant route
puts a caller-chosen tenant back in the one place this system refuses to
take one, filtering the estate in the browser ships every merchant's
data to render one, and signing in as the owner audits the wrong person.
So the tenant STORE functions are reused with an explicit client id -
they already take one - and the scoping the tenant handlers get from the
session is done in the handler instead.

AdminCamera is a separate type from Camera, for the same reason
AgentCamera is. It cannot carry host, port, path, username or
has_password. A tenant seeing those for their own camera is correct; a
platform admin browsing another company's estate is a different
question, and an RTSP host with a username beside it is most of a live
path into a customer's camera. Blanking fields on a shared struct leaves
'remember to redact, on every path, forever' as the only thing
preventing a leak. The test asserts on the raw JSON, because decoding
into the struct would discard exactly what it is looking for.

An unowned site is 404, never an empty list. The tenant resolver returns
a uuid untouched and lets client_id =  downstream scope it, which is
sound only because that id comes from a session; here the caller names
both halves, so an unowned uuid would reach a query that quietly returns
nothing - 'this shop has no cameras' when the truth is 'not your shop'.
Both resolvers check the whole chain in one statement.

Two things the in-memory fake could not have caught, so neither was left
to it. The fake ignored clientID in SiteHealth and Cameras, which would
have made every cross-merchant test pass while returning another
company's shops; it is client-aware now for these paths. And the SQL was
written to make the documented $2-deduced-as-two-types bug impossible
rather than to be caught by a database later: id::text = $2 in place
of id = $2::uuid, one type per parameter, which also turns a malformed
path segment into the 404 it should be instead of a cast error.

Every read below the merchant list writes an audit row naming the admin
and the merchant - an admin is the one account for which nothing else
here leaves a trace. The counts-only summary does not: a console
refreshes it on a timer, and logging that buries the reads worth
finding. A suspended merchant stays readable, because that is precisely
what an admin opens the console to look at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 16:01:22 +05:30