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:
198
CLAUDE.md
198
CLAUDE.md
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user