Files
Behavision/server/migrations/010_invitations.sql
Suriyakumarvijayanayagam 3f9fb33b24 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
2026-09-05 11:45:42 +05:30

61 lines
3.0 KiB
PL/PgSQL

-- Adding a second person to a company.
--
-- Until now a tenant had exactly the users `provision user` had created on the
-- server's own command line. That is not a gap in a UI, it is a gap in the
-- product: a shop with an owner and four staff either shares one password
-- between five people or raises a support ticket to add each of them, and a
-- mobile app for shop floor staff cannot exist at all when there is only one
-- account to sign in with.
--
-- Registration is by INVITATION, never open signup. That is the same line
-- `handlers_admin.go` already draws for creating a company: an endpoint a
-- stranger can call to create an account is a much larger thing to secure than
-- one reachable only through a manager who already has one, and a self-created
-- account in a tenant is a row nobody asked for holding a place in a table
-- every query joins against.
--
-- The single-use guarantee is the same one enrolment codes use, and for the
-- same reason: it lives in the UPDATE (`used_at IS NULL` and the write are one
-- statement), never in a check followed by a write, so two people racing on one
-- invitation cannot both win.
BEGIN;
CREATE TABLE IF NOT EXISTS invitations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
-- The address the invitation was issued FOR. It becomes the account's
-- address on redemption and is not caller-supplied at that point: letting
-- the redeemer choose would turn one invitation into an account for
-- anybody who was forwarded the email.
email text NOT NULL,
full_name text NOT NULL DEFAULT '',
-- 'admin' is deliberately NOT allowed. A platform administrator is defined
-- by having no client at all, so an invitation - which always carries one -
-- could never mint a real one; what it could do is put the string 'admin'
-- on a tenant-scoped row, and `adminOnly` guards against exactly that
-- combination existing. Refusing it here means it cannot be created in the
-- first place.
role text NOT NULL DEFAULT 'staff',
-- Only the hash. An invitation is a credential for as long as it is
-- unused, so a database dump must not contain a working one - the same
-- rule sessions and enrolment codes already follow.
code_hash bytea NOT NULL UNIQUE,
invited_by uuid REFERENCES app_users(id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL,
used_at timestamptz,
used_by uuid REFERENCES app_users(id) ON DELETE SET NULL,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT invitations_role_known
CHECK (role IN ('owner', 'manager', 'staff'))
);
-- Pending invitations only. The list a manager looks at is "who has been asked
-- and has not joined yet"; spent and revoked rows are history.
CREATE INDEX IF NOT EXISTS invitations_pending_idx
ON invitations (client_id, created_at DESC)
WHERE used_at IS NULL AND revoked_at IS NULL;
COMMIT;