# Accounts

Sources of truth: `main/app/services/accounts.py`, `main/app/services/identity.py`,
`main/app/services/sessions.py`, `main/app/services/access.py`, `main/app/services/passwords.py`,
`main/app/services/directory.py`, `main/app/services/mail.py`, `main/app/services/audit_log.py`,
`main/app/services/view_as.py`, `main/app/routers/auth.py`, `main/app/routers/ops.py`,
`main/app/cli_accounts.py`, `main/app/models/accounts.py`, `main/app/vendor/`, `main/tests/kit/`.

The site's own account system: sign-in with a hashed password, revocable sessions, levels of
access with the owner synthesised as super admin, View As with an owner-only act mode, an
audit trail, and a members-only mode the go-live checklist flips to public. It is modelled on
the server's BW application kit and completely independent of it, so the codebase can be
handed to an owner with no relationship to the server it was built on. Design record:
`.logs/planning/accounts-2026-09.md`; the procedure for a host: `RUNBOOK.md` (Accounts).

## What is vendored, and the one rule about it

The kit's four modules sit in `app/vendor/` and its React pack in `web/src/vendor/bw-admin/`,
byte-identical. `scripts/vendor-check.sh` (run by `check.sh`) compares each file against the
kit when it exists on the machine and against `app/vendor/MANIFEST.sha256` when it does not;
`scripts/vendor-refresh.sh` is the only way they change. A fix to a vendored file is a
proposal in `.logs/planning/kit-upstream-proposals.md`, applied upstream, then re-vendored.
The kit's central client (`bw_auth.py`) is never vendored: `check.sh` grep-gates `main/app`
against any import of it, and against `X-Auth-User` and `X-Auth-Grant` (once the server's gate
lifts, nothing strips those headers, so the app must never read them).

The React pack compiles as its own TypeScript project reference (`web/tsconfig.vendor.json`)
without the unused-symbol flags, because eight pack files carry an unused default `React`
import; the main project keeps `noUnusedLocals`.

## Identity

`accounts` is the one identity table and the FK target of every who-column. `username` is the
kit's key and the `@mention` handle: lowercase, immutable, `^[a-z0-9][a-z0-9._-]{1,31}$`.
`email` is the login alias; `display_name` the only editable name; `status` is `active`,
`invited` (no password yet) or `disabled`. The credential (argon2id hash, the must-change
flag, the lockout counters) lives in `account_credentials`, so no query over `accounts` can
ever carry a hash. The kit's membership is three DFP-owned tables the vendored store maps onto
(`account_levels`, `account_members`, `account_grants`); a consumer later holds an `accounts`
row and no member row, so the permission tables stay staff-sized. Instances map read-only
onto `brands.slug`, unused until a brand-manager tier turns `has_instances` on.

**The owner is code, not a row.** `ACCOUNT_OWNER=<username>` in `.app.env` names the super
admin; the kit synthesises every permission for that name. Unset or a placeholder hands the
kit the sentinel `!no-owner` (the kit defaults an owner on any falsy value, and a silently
defaulted owner is a footgun in a copied kit): nobody is owner, the startup log says
CRITICAL, `accounts owner-check` says the same, and the site stays up. The owner still needs
an `accounts` row and a password to sign in.

## Permissions and levels

Six DFP permissions, registered with the kit as enforced so the Levels editor labels them
honestly: `client.view` (the client pages and their reads), `client.participate` (comment,
edit one's own comment, set a priority, toggle a quote line, send a quote request, complete or
reopen a to-do, upload a hand-in), `discussion.curate` (add, reword or resolve a decision card;
edit anyone's comment), `plan.view` (`/plan`, `/issues`, the running list and its `item:*`
threads), `items.act` (decide, done, dismiss, reopen), `sources.manage` (`/sources`, the
collector health read, the kill switch). The kit's own `accounts.*`, `levels.*` and
`instances.*` ride along.

`backfill levels` seeds two levels, insert if absent and never update: `admin` (`client.view`
and `client.participate`; assignable nothing) and `member` (nothing). The kit has no rename,
so a name is fixed at its first seed. The four other DFP permissions and `accounts.view_as`
are held by no level, so at launch they are the owner's by absence. The kit's own templates
(`admin`, `manager`, `team`, `collaborator`) are adoptable from the Levels editor under names
that do not collide, for the day account administration is delegated.

Every permission question goes through `accounts.can()` and `accounts.is_owner()`, never a
level name; the SPA reads the `/api/bw/me` booleans and the DFP `capabilities` block
(`client_view`, `client_participate`, `discussion_curate`, `plan_view`, `items_act`,
`sources_manage`, `must_change_password`) and switches on nothing else.

## Sessions and the cookie

An opaque 256-bit token rides in the cookie; its sha256 is the `sessions` row's key. No
signing secret sits in the session path, so a second host or worker needs no shared key and a
database read yields nothing a browser could present. The cookie is `__Host-dfp_session` with
`Secure` when `APP_ENV` is production (the compose default, and a local rehearsal with no
`APP_ENV`), `dfp_session` without `Secure` only when `APP_ENV` is anything else; never the
request scheme, which is `http` behind the proxy. `HttpOnly; SameSite=Lax; Path=/`, no
`Domain`. Absolute 30 days, no sliding. Logout revokes; a password set, reset or change
revokes every other session of the account; disabling, and removal from the People panel,
revoke all; `sessions revoke --username | --all` is the break-glass.

The loader middleware runs outermost: it loads the row on every request, attaches it as
`request.state.session`, and flushes the kit's View As keys after the response if the request
changed them. That flush is the one GET that writes: confined to `sessions.acting_as` and
`acting_mode` plus the audit row, on the store's own short connection, never inside the
request's `get_db` session. `POST /api/auth/heartbeat` (every five minutes from a visible
tab) moves `last_seen_at` and nothing else.

## Passwords, lockout, tokens

argon2id, `time_cost=3`, `memory_cost=32 MiB`, `parallelism=1`, above the OWASP floor; a
`Semaphore(2)` around hash and verify so a login flood queues instead of starving page
serving; `accounts bench` prints the cost on the actual host. Rules: 12 to 256 characters,
NFC-normalised, no composition rules, refused when it contains the username or the email's
local part.

Every refused login is the same `401 LOGIN_REFUSED` with no `Set-Cookie`: an unknown name,
an invited account, a disabled one, a locked one and a wrong password all spend a real or a
dummy verify of the same cost, so the response sequence never tells a name that exists from
one that does not. Two lockout layers: per account in the database (8 failures within 15
minutes lock for 15 minutes; a success or `accounts unlock` resets; the lock is silent and the
audit row says `locked`) and a per-address throttle in the process (10 per address per 15
minutes, 60 a minute overall; the only `429`, with `Retry-After`). Failures against a name with
no row count in a second in-process throttle keyed on the folded name. The address throttle
and the argon2 semaphore are per worker: one uvicorn worker is a launch assumption, and a
`login_attempts` table comes before a second.

A welcome or reset token is `secrets.token_urlsafe(32)`, stored hashed in `account_tokens`,
single use (marked used in the same transaction as the password write), 48 hours, minted
only for non-disabled accounts, and built from `PUBLIC_BASE_URL` so a staging link never lands
on production. **The token travels only in the link's fragment** (`/welcome#<token>`,
`/reset#<token>`) and then in a JSON body: a browser never sends a fragment, so the
container's stdout, which is an access log of every request line, never sees it. No route
under `/api/auth` and no SPA path carries a token segment, and a test asserts it.
`POST /api/auth/token-state` answers `valid | expired_resendable | used | unknown` and writes
nothing. Redeeming revokes every other session and signs the browser in.

`must_change_password` is set by the generated-password paths (`create_user_with_password`,
`reset_password_generated`) and cleared by a change, a welcome or a reset. While set, the
access policy admits only the change-password, logout, `/api/bw/me`, `/api/whoami`, the
public routes and the SPA shell; everything else answers `403 PASSWORD_CHANGE_REQUIRED` or
`302 /account`, so a handed-over password can do nothing but replace itself.

## The route policy

`services/access.py` puts every route, page, mount and SPA path in exactly one class:
`PUBLIC_ALWAYS` (the sign-in machinery, health, `/api/bw/me`, robots, the static mounts, the
shell's root files by exact path), `PUBLIC_WHEN_OPEN` (every storefront read, the page
routes, the machine files, the not-found shell; pinned to `tests/fixtures/open_routes.json` so
opening a route is a reviewed diff), `MEMBER` (any signed-in account; the kit's routes listed
one by one, so a re-vendor that adds a route fails the inventory), and `PERMISSION` (one
string, or `owner`). A route in no class answers `403 ROUTE_NOT_CLASSIFIED` before its
handler runs. `decide()` is pure and walked case by case in `tests/test_access.py`.

Order: unclassified; Origin on every non-safe request (an `Origin` or `Referer` whose host is
the request host or `PUBLIC_BASE_URL`, required, the CSRF check for every write including the
kit's); read-only View As on every non-safe request but stop and logout; then, for a
non-public route, `302 /login?next=<path>` for a page or `401 NOT_SIGNED_IN` for JSON;
`ACCOUNT_DISABLED`; the must-change gate; the permission (`403 FORBIDDEN`, or the 404 shell
for a page under `plan.view`, so a client is not told `/plan` exists). Whether an answer is a
page or JSON comes from the route key, never the `Accept` header. `safe_next()` is the only
place a redirect target is accepted, on the server and as `safeNext()` in the SPA; the two
share `tests/fixtures/next_cases.json`.

**Members-only mode** (`SITE_ACCESS=members`, the code default, fail closed): every anonymous
page request answers one `302 /login`, known route or not, so route existence is not
disclosed and no server-rendered price page is ever built for an anonymous caller;
`/robots.txt` answers `User-agent: *` / `Disallow: /` with no Sitemap line; `/sitemap.xml`,
`/feed.xml` and `/llms.txt` answer a plain 404; every response carries `X-Robots-Tag: noindex,
nofollow`; FastAPI's docs are off in production in any mode. The flip to `public` is one env
line and a container recreate: the storefront opens, robots returns the per-bot policy with
the Sitemap line, the machine files answer, the `X-Robots-Tag` stops, the not-found shell
answers 404 to anonymous callers. Nothing else moves.

## Authorship, View As, audit

Every write that names a person takes it from the session: the who-column gets the effective
account's id and the text column its display name as a snapshot (the raw record). The
`item:*` comment threads are filtered from the list read and answer 404 to a post or an edit
unless the caller holds `plan.view`. A comment is editable by its author under
`client.participate` and by anyone under `discussion.curate`.

View As is the kit's mechanism with DFP's policy: the owner, or a level holding
`accounts.view_as`, may view as any active account that is not the owner, member or not; act
mode is the owner's alone; read-only mode blocks every mutation but stop and logout. In act
mode a write stores the effective account's id, and the access middleware writes one
`act_as.write {key, status}` row with `account_id` the real account and `acting_as_id` the
effective one, the one record of who really acted. Active impersonations are one SELECT over
`sessions` (`GET /api/ops/impersonations`, owner).

`audit_log` rows carry the real and the acting-as account, the session hash and the address,
filled from the request's session for every row, the kit hook's included. Actions: the kit's
(`kit.add_member`, `kit.set_member_level`, `kit.create_level`, ...), `login.ok`,
`login.refused {reason: bad_password | unknown | locked | disabled | invited}`, `login.locked`,
`logout`, `session.revoke`, `password.set {via}`, `token.mint`, `token.redeem`,
`account.create | disable | enable | unlock`, `view_as.start | stop` (the fail-closed auto-stop
carries `auto: true`), `act_as.write`. Never a password, a token, or a typed unknown username.
On `remove_member` the sink runs the same path as `accounts disable`, because the kit's own
remove only flips `account_members.active`, which neither login nor the session loader reads,
while the panel's Remove button says it revokes access; re-adding answers `DISABLED` until
`accounts enable`, which never re-adds the member row.

## The local directory and mail

`LocalDirectory` is what the kit calls its central client, duck-typed: `userinfo` raises for an
absent row and resolves a disabled one with its status (so the kit's add path refuses it with
`DISABLED` rather than treating it as new); `invite_user` and `create_user_with_password`
insert the principal inside a savepoint and answer `EXISTS` on any existing username or email
without continuing (the savepoint-and-re-read rule is for shared domain rows, not
principals); a generated password is 16 characters of the unambiguous alphabet, grouped
`xxxx-xxxx-xxxx-xxxx`, returned once and never stored or logged. `services/mail.py` is the
seam: provider `none` (the default) raises `MailNotConfigured` before anything is written,
which the People panel shows as the invite failing with the alternatives; there is
deliberately no console provider. Until a provider exists the CLI is the invite path:
`accounts invite-link` and `reset-link` print a one-time link once to the operator's terminal.

## Tests

`tests/kit/` holds the kit's five conformance files byte-identical (part of the vendor
manifest); their contract (`as_user`, `as_session`, `OWNER`, `HAS_INSTANCES`) lives in the root
`tests/conftest.py` and their fixtures (`client`, `kit`, `bw_calls` over a recorder that wraps
the directory, the recording mailer, the store reset that seeds the kit's two templates and
the stubbed principals) in `tests/kit/conftest.py`, so no pure DFP test acquires an accounts
store. The accounts suites run in-process against an in-memory SQLite holding the auth
tables (`tests/kit/_env.py`), still no server and no network, with argon2 at a fraction of
its production cost; the production parameters are pinned separately.
