Accounts people can create, and photos on a server with no bucket

A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.

Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.

Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.

Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.

Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.

Found by running it, not by tests:

  * UPDATE ... RETURNING gives the value AFTER the update, so the prune
    read back empty keys, deleted nothing, and the table grew with
    footfall exactly as if it were not there. The fake agreed with either
    version; only the live Postgres test caught it.
  * Trusting only the auth flag broke every shop card, because Sites.jsx
    rebuilt a partial snapshot object and dropped it. A relative URL is
    now sufficient on its own.
  * ago() renders a future time as "just now", so a code valid for a week
    read "expires just now".

Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
2026-09-05 11:45:42 +05:30
parent ffae7e45d5
commit 3f9fb33b24
38 changed files with 4125 additions and 106 deletions

198
CLAUDE.md
View File

@@ -2408,3 +2408,201 @@ not in normal running — but the margin is what makes `/api/health` reporting
already holds **17 embeddings tagged `w600k_mbf` and 19 tagged `w600k_r50`**:
proof that the fallback has silently fired before, and that model-tagging is
what stopped it corrupting anything.
## Accounts: how a second person gets one (`invitations`, migration 010)
A tenant had exactly the users `provision user` had created on the server's
command line. That is not a missing screen, it is a missing product: a shop with
an owner and four staff either shared one password between five people or raised
a support ticket per person, and **a phone app for shop-floor staff could not
exist at all** while there was only ever one account to sign in as.
Registration is by **invitation**, never open signup — the same line
`handlers_admin.go` already draws around creating a company. An endpoint a
stranger can call to create an account is a far larger thing to secure than one
reachable only through somebody who already has one.
```
POST /api/team/invitations manager+ -> the code, ONCE
GET /api/auth/invitation?code=… unauthenticated preview
POST /api/auth/register unauthenticated -> a SESSION
```
- **The code decides the address and the role; the request decides only the
password and a display name.** A code gets forwarded, screenshotted and
pasted into chat, so if the body could name either, one staff invitation would
be an owner account for anybody who saw it. `decode` rejects unknown fields,
so a client cannot even ask — verified live: `unknown field "role"` → 400.
- **`register` returns a session, not a 201.** Sending somebody who chose a
password four seconds ago to a sign-in form to type it again is the sort of
thing that gets blamed on the password.
- **Single use is enforced by the UPDATE** (`used_at IS NULL` and the write are
one statement) and the account is created **in the same transaction**. A spent
invitation with no user is unusable and invisible; a user with the invitation
still open is a second account waiting for whoever else has the code. Same
rule, same reason, as agent enrolment.
- **Unknown, expired, spent and revoked read identically.** The difference only
helps somebody guessing, and the holder's next step is the same in all four.
- **`admin` is not an invitable role.** A platform administrator is defined by
having *no* client, so an invitation — which always carries one — could never
mint a real one. What it *could* do is create the tenant-scoped `role='admin'`
row that `adminOnly` exists to reject, so it is refused at the constraint.
- **A manager cannot mint an owner.** Promoting somebody past yourself is an
escalation, and it is the shape of this endpoint that matters if a manager
account is ever taken over.
- A failed attempt (short password, mistyped code) does **not** spend the
invitation. One typo must not cost somebody their invitation.
### Removing access has to mean now
`PATCH /api/team/{id}` with `{"active": false}` revokes every session that user
holds **in the same transaction**. An access token lives twelve hours, so
without that, "remove their access" removes it sometime tomorrow — which is not
what anybody pressing that button believes they have just done.
`OwnerCount` refuses the change that locks a company out of itself: the last
active owner may not demote or deactivate themselves. There is no way back from
that except a shell on the server, which is precisely what this surface exists
to stop needing.
### Devices: the benefit of opaque tokens, finally collected
`GET /api/auth/sessions`, `DELETE /api/auth/sessions/{id}`,
`POST /api/auth/sessions/revoke-others`.
The argument for a session table over JWTs was always that this system puts
customer data on shop-floor PCs and staff phones that get lost, resold and
shared — so *"log that device out, now"* has to actually work. **Nothing could
list what was signed in, let alone stop one.** The cost was being paid and the
benefit was not being collected.
- A person may revoke only their **own** sessions; the store scopes the update
by `user_id`, because a session id travels in that list and is not a secret.
Removing a colleague's access is a different question with a different answer
(deactivate them).
- **"Sign out everywhere else" keeps the caller's own session.** Somebody who
has just lost a phone must not also be signed out of the device they are
holding while they deal with it.
- `device` is a coarse label (`"Chrome on Mac"`), never a fingerprint. The
question it answers is only *"which of these is the one in my hand"*.
## Face images without an object-storage bucket (`visit_faces`, migration 011)
009 did this for camera snapshots and its own comment says why face images are
different: *"Face images grow with every visitor who ever walks in, which is why
they stay in a bucket."* That is true of images kept **per visit**, and it is
exactly why this table is bounded to **one row per visitor** instead.
The gap it closes is the one 009 closed a level up. With no bucket the API
answered *"This system is not storing customer photos"* for every arrival,
forever — including on the mobile feed, whose entire purpose is to put a face in
front of somebody so they can recognise the customer walking towards them. Every
local install and every self-hosted customer who does not want an S3 account got
nothing.
```
engine data/outbox/<uuid>.jpg (only when app.store_faces is on)
agent POST /api/agent/upload-url -> 501 images_disabled
POST /api/agent/faces -> {"key": "db:<uuid>"}
server visits.image_key = 'db:…'
staff GET /api/visits -> {"image":{"available":true,
"url":"/api/faces/<uuid>.jpg",
"auth":true}}
```
What makes this acceptable in Postgres when per-visit images are not:
- **The engine still gates capture.** `app.store_faces` is false by default and
no crop is written without it. This changes what happens to an image that
already exists; it does not change whether one is taken.
- **One row survives per visitor.** `RecordVisit` prunes the previous row as it
links a newer one, so storage is (customers × ~20 KB) — it grows with the
customer base, not with footfall. A shop seen by 5,000 people holds ~100 MB
whether they visit once or a thousand times.
- **Nothing reads a superseded face anyway.** Every surface shows the customer's
latest view, which is what `VisitorImageKey` has always returned.
- **Orphans are swept.** An agent uploads before the server has decided who the
person is, so a row is briefly unreferenced by design — and permanently so if
the visit that would have claimed it never arrives. That is a stored
photograph of a real person that erasure could never reach, because erasure
finds images through the visitor and this row has none.
The bucket stays primary wherever one exists: a presigned PUT never passes the
bytes through the API at all, which is what makes it the right route at estate
scale. The fallback is chosen by the **sentinel** `bridge.ErrImagesOff`, never
by matching a message — getting that wrong from prose somebody later rewords
would silently stop every customer photo in the estate. Same rule the camera
snapshot fallback already follows.
### `UPDATE … RETURNING` returns the value AFTER the update
The prune's first version read the superseded keys with
`UPDATE visits SET image_key = '' … RETURNING image_key`. Postgres returns the
**new** row, so every key came back as the empty string it had just been set to,
the delete list was always empty, and `visit_faces` grew with footfall exactly
as if the prune did not exist. The visit rows looked perfectly correct; only the
row count gave it away.
It is one CTE now — `doomed` reads the pre-image and drives both the update and
the delete — which cannot have that bug. **The in-memory fake would have agreed
with either version**; only `TestLiveOnlyOneFaceSurvivesPerVisitor` against a
real Postgres caught it, which is the whole reason the live store tests exist.
### `Image.auth`, and one function that decides where a photo is
`s.imageFor(key)` is the single place that turns a stored key into the `Image` a
client receives — the arrivals feed, the live stream and the customer record all
go through it. There are now two places an image can live and four distinct
reasons there may not be one, and computing that twice is how the shops screen
once ended up labelled **Working** in green directly above *"2 of 3 cameras not
connecting"*.
`auth: true` says the URL is one of ours and needs the session's bearer, rather
than a presigned link carrying its own signature. It exists because the two are
genuinely different to fetch and **a client cannot tell them apart by looking**:
- A browser `<img>` **cannot** load the authenticated one — no header — so the
web app fetches it and hands over an object URL (`Shot.jsx`).
- A **mobile** image view *can* attach the header and load it directly.
- The **desktop** webview can do neither: a relative src resolves against
`wails://`, not the cloud. `cloud.VisitorImage` therefore fetches the bytes in
Go, where the session already lives, and returns a `data:` URI. The
alternative — a local proxy inside the app holding the session — is a second
authenticated surface on a shop PC to get wrong.
**Both signals are accepted, and that is not belt-and-braces.** A relative URL
always needs the session; there is no public one. Trusting only the flag broke
every shop card the moment `Sites.jsx`'s `bestView()` rebuilt a partial
`{url, at}` copy and dropped it — found by opening the page, not by a test. The
flag adds only the case a URL cannot express: an absolute link that still needs
a bearer, which arrives the first time object storage is served from this host.
The bytes endpoint writes **no audit row**. Every read of a face is recorded
where the *link* is handed out — one row per arrivals page, one per customer
record — and the bucket route's bytes never touch this server, so counting the
fetch as well would count one deployment twice and the other once.
`ago()` clamps at zero and renders a future timestamp as *"just now"*. That is
right for a heartbeat whose clock runs slightly ahead and completely wrong for
an expiry: a code valid for a week read *"expires just now"*, which tells the
operator not to bother handing it over. `until()` is its opposite number.
### Verified live, 5 September 2026
Against real Postgres, on the demo tenant:
- Owner invites a staff member → code minted once → unauthenticated preview
names the company, address and role → a body naming `role` or `email` is
refused → proper redemption returns a **signed-in session** → replay 404s.
- Staff can read arrivals, shops and the team; **cannot** invite (403).
- Two devices listed, the calling one marked `current`; revoking the phone 401s
its token immediately while the till keeps working.
- Deactivating a member 401s their live session **at once**, and they cannot
sign back in. The only owner cannot demote themselves (409 `last_owner`).
- Agent enrols → `upload-url` answers **501 images_disabled** → falls back to
`POST /api/agent/faces` → a 92,405-byte office-camera JPEG stored in Postgres,
served as `image/jpeg` to the owner, **401 with no session**, **404 to another
tenant**, and rendered in the arrivals feed avatar in a real browser.
- HTML, PDF, GIF and empty bodies are all refused as face images: the check is
on the magic bytes, never the `Content-Type` header, because this endpoint
stores what it is handed and serves it back to a browser.