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

View File

@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
@@ -70,6 +71,14 @@ type fakeStore struct {
lastCheckKind string
lastCheckSeconds int
// Invitations, and the faces this server holds itself.
invites map[string]*fakeInvite // by code hash hex
faces map[string][]byte // "client/id"
lastFaceClient string
lastFaceSite string
deletedFaces []string
faceDeleteErr error
cameras []Camera
agentCameras []AgentCamera
lastCameraReport AgentCameraReport
@@ -97,6 +106,7 @@ type fakeSession struct {
id string
p auth.Principal
accessExp, refreshExp time.Time
device string
revoked bool
}
@@ -150,7 +160,11 @@ func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
f.mu.Lock()
defer f.mu.Unlock()
f.nextID++
id := "sess-" + itoa(f.nextID)
// uuid-SHAPED, because the handlers validate the shape of an id before
// spending a database round trip on it. A fake that mints "sess-1" would
// make every id-addressed session route 404 in tests and pass in
// production, which is the wrong way round.
id := fmt.Sprintf("00000000-0000-4000-8000-%012d", f.nextID)
var rec UserRecord
for _, u := range f.users {
if u.ID == n.UserID {
@@ -165,6 +179,7 @@ func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
FullName: rec.FullName, Role: rec.Role,
},
accessExp: n.AccessExpiry, refreshExp: n.RefreshExp,
device: n.Device,
}
f.sessions[id] = s
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
@@ -673,3 +688,228 @@ func (f *fakeStore) addCameraRef(id, client, site, engineID string) {
}
type cameraRef struct{ client, site, engineID string }
// ==================================== team, invitations, sessions, faces ====
//
// These behave rather than merely satisfy the interface: single use, tenant
// scoping and "the role comes from the invitation" are the properties the
// handlers are trusted for, so a fake that always says yes would make the tests
// that check them meaningless.
type fakeInvite struct {
id, clientID, email, fullName, role string
expires time.Time
used, revoked bool
}
func (f *fakeStore) CreateInvitation(_ context.Context, in NewInvitation) (Invitation, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.invites == nil {
f.invites = map[string]*fakeInvite{}
}
f.nextID++
id := fmt.Sprintf("00000000-0000-4000-9000-%012d", f.nextID)
f.invites[hex.EncodeToString(in.CodeHash)] = &fakeInvite{
id: id, clientID: in.ClientID, email: in.Email,
fullName: in.FullName, role: in.Role, expires: in.ExpiresAt,
}
return Invitation{
ID: id, Email: in.Email, FullName: in.FullName, Role: in.Role,
ExpiresAt: in.ExpiresAt.UTC().Format(time.RFC3339),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}, nil
}
func (f *fakeStore) PendingInvitations(_ context.Context, clientID string) ([]Invitation, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []Invitation
for _, v := range f.invites {
if v.clientID != clientID || v.used || v.revoked {
continue
}
out = append(out, Invitation{ID: v.id, Email: v.email,
FullName: v.fullName, Role: v.role,
ExpiresAt: v.expires.UTC().Format(time.RFC3339)})
}
return out, nil
}
func (f *fakeStore) RevokeInvitation(_ context.Context, clientID, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, v := range f.invites {
if v.id == id && v.clientID == clientID && !v.used && !v.revoked {
v.revoked = true
return nil
}
}
return errors.New("no such pending invitation")
}
func (f *fakeStore) InvitationByCode(_ context.Context, hash []byte) (InvitationPreview, error) {
f.mu.Lock()
defer f.mu.Unlock()
v, ok := f.invites[hex.EncodeToString(hash)]
if !ok || v.used || v.revoked || time.Now().After(v.expires) {
return InvitationPreview{}, errors.New("that invitation is not valid")
}
return InvitationPreview{Client: "Fake Co", Email: v.email,
FullName: v.fullName, Role: v.role}, nil
}
func (f *fakeStore) RedeemInvitation(_ context.Context, hash []byte,
fullName, passwordHash string) (UserRecord, error) {
f.mu.Lock()
defer f.mu.Unlock()
v, ok := f.invites[hex.EncodeToString(hash)]
if !ok || v.used || v.revoked || time.Now().After(v.expires) {
return UserRecord{}, errors.New("that invitation is not valid")
}
if _, taken := f.users[v.email]; taken {
return UserRecord{}, errors.New("app_users_email_idx")
}
// Marked spent BEFORE the account exists, mirroring the real store's one
// transaction: a test that redeems the same code twice must get one user.
v.used = true
f.nextID++
rec := UserRecord{
ID: fmt.Sprintf("00000000-0000-4000-a000-%012d", f.nextID),
// From the INVITATION, never from the request - which is the property
// worth having a fake at all for.
ClientID: v.clientID, ClientName: "Fake Co", Email: v.email,
FullName: fullName, Role: v.role, Active: true, Found: true,
PasswordHash: passwordHash,
}
if rec.FullName == "" {
rec.FullName = v.fullName
}
f.users[v.email] = rec
return rec, nil
}
func (f *fakeStore) Team(_ context.Context, clientID string) ([]TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []TeamMember
for _, u := range f.users {
if u.ClientID != clientID {
continue
}
out = append(out, TeamMember{ID: u.ID, Email: u.Email,
FullName: u.FullName, Role: u.Role, Active: u.Active})
}
return out, nil
}
func (f *fakeStore) UpdateTeamMember(_ context.Context, clientID, userID string,
up TeamUpdate) (TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
for email, u := range f.users {
if u.ID != userID || u.ClientID != clientID {
continue
}
if up.Role != nil {
u.Role = *up.Role
}
if up.Active != nil {
u.Active = *up.Active
if !u.Active {
// The real store revokes in the same transaction; the fake
// does it here so a test can prove "they have left" actually
// signs them out rather than waiting twelve hours.
for _, s := range f.sessions {
if s.p.UserID == userID {
s.revoked = true
}
}
}
}
f.users[email] = u
return TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName,
Role: u.Role, Active: u.Active}, nil
}
return TeamMember{}, errors.New("no such team member")
}
func (f *fakeStore) UserSessions(_ context.Context, userID string) ([]DeviceSession, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []DeviceSession
for _, s := range f.sessions {
if s.p.UserID != userID || s.revoked {
continue
}
out = append(out, DeviceSession{ID: s.id, Device: s.device,
ExpiresAt: s.refreshExp.UTC().Format(time.RFC3339)})
}
return out, nil
}
func (f *fakeStore) RevokeUserSession(_ context.Context, userID, sessionID string) error {
f.mu.Lock()
defer f.mu.Unlock()
s, ok := f.sessions[sessionID]
// Scoped by user id, exactly as the real UPDATE is: a session id travels in
// a list and is not a secret, so it must not sign anybody else out.
if !ok || s.p.UserID != userID || s.revoked {
return errors.New("no such session")
}
s.revoked = true
return nil
}
func (f *fakeStore) RevokeOtherSessions(_ context.Context, userID, keep string) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, s := range f.sessions {
if s.p.UserID == userID && s.id != keep && !s.revoked {
s.revoked = true
n++
}
}
return n, nil
}
func (f *fakeStore) PutVisitFace(_ context.Context, clientID, siteID string,
jpeg []byte) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.faces == nil {
f.faces = map[string][]byte{}
}
f.nextID++
id := fmt.Sprintf("00000000-0000-4000-b000-%012d", f.nextID)
f.faces[clientID+"/"+id] = jpeg
f.lastFaceClient, f.lastFaceSite = clientID, siteID
return "db:" + id, nil
}
func (f *fakeStore) VisitFace(_ context.Context, clientID, key string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
img, ok := f.faces[clientID+"/"+strings.TrimPrefix(key, "db:")]
if !ok {
return nil, errors.New("no such face image")
}
return img, nil
}
func (f *fakeStore) DeleteVisitFaces(_ context.Context, clientID string, keys []string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.faceDeleteErr != nil {
return f.faceDeleteErr
}
for _, k := range keys {
delete(f.faces, clientID+"/"+strings.TrimPrefix(k, "db:"))
f.deletedFaces = append(f.deletedFaces, k)
}
return nil
}