# Stream R: accounts and login (written 2026-09-10 from rian's ask of the same day)
> **Status 10 Sep:** not started. The design is `.logs/planning/accounts-2026-09.md` (read it in full first; this brief is the task list, not the design). R1 runs as `/stream-r`; R2 (`/stream-r2`) and R3 (`/stream-r3`) after the 18 Sep delivery.
**Runs:** autonomously, alone in the tree or beside E (which touches only `deploy/`, the
production overlay and the droplet). Never deploys; rian holds the deploy and migration tokens.

## Goal
The site's own account system, modelled on the server's BW application kit and independent of
the server: 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, authorship from
the session on every write, and a members-only mode that the go-live checklist later flips to
public. Three accounts at launch (rian; Adam and Mark on `admin`), seeded per host by CLI.

## R1, in order (ids are `/plan` tasks; record each with `main/scripts/plan-set.py`)
1. **L1 Vendor the kit** (plan §4.1). Byte-identical copies of `bw_accounts.py`,
   `bw_store_sqlalchemy.py`, `bw_admin_api.py`, `bw_view_as.py` into `main/app/vendor/` and the
   whole `react-admin/` folder into `main/web/src/vendor/bw-admin/`; `MANIFEST.sha256`;
   `main/scripts/vendor-check.sh` (byte-compare against `/srv/system/id-auth/app-auth/` when it
   exists, the manifest when it does not) and `vendor-refresh.sh`; `check.sh` runs the check and
   a grep gate that keeps `main/app` free of `X-Auth-User`, `X-Auth-Grant` and `bw_auth`.
   Eight pack files carry an unused default `React` import that `main/web/tsconfig.json`'s
   `noUnusedLocals` rejects (TS6133): compile the vendored folder under a
   `main/web/tsconfig.vendor.json` project reference without the unused-symbol flags and
   `exclude: ["src/vendor"]` in the main tsconfig (`tsc -b` is already the driver); if that shape
   fights `tsc -b` within an hour, relax `noUnusedLocals` and `noUnusedParameters` with a comment
   naming the sixth upstream proposal, restored when the kit is re-vendored.
   `argon2-cffi>=23.1` in `pyproject.toml` and `../.venv-dev/bin/pip install "argon2-cffi>=23.1"`.
   Test: `check.sh` fails when a vendored byte changes and passes when it is refreshed, and
   `npx tsc -b` is green with the pack vendored and untouched.
2. **O2 Migration #4** (plan §6). One revision, `down_revision = "a4b5c6d7e8f9"`, schema-only,
   every column NULL or server-defaulted, JSON columns as `JSON().with_variant(JSONB,
   "postgresql")`; the models in `models/accounts.py` (a new module) and the seven `*_id`
   columns on their existing models; `cli_accounts.py` (`accounts create | set-password |
   invite-link | reset-link | disable | enable | unlock | list | owner-check | bench`,
   `sessions revoke | prune`, `audit-log prune`) registered like `cli_quality.register`; the
   backfills `accounts` (extended), `levels`, `authors`, `overrides` join `BACKFILLS` in
   `main/app/cli.py`; the one `backfill` parser there gains `--check` (print what would change,
   write nothing, exit 0; every name honours it), `--map K=V` (repeatable) and
   `--include-defaulted` (`authors` only; any other name rejects them), and `cmd_backfill`
   passes `args` through as `fn(db, args)` with the existing backfills ignoring it. Rehearse on `dfp-devdb`: restore the newest nightly dump as `dfp_r`, `upgrade
   head`, every backfill twice (the second run changes zero rows), `downgrade -1`, `upgrade head`.
   Test: the backfills' idempotency on an in-memory SQLite; `alembic heads` one head.
3. **L3 Identity** (plan §4.3 to §4.5). `services/passwords.py`, `services/sessions.py` (the
   store, `SessionRow`, the cookie, the loader middleware, the one GET-write exception on its
   own connection), `services/directory.py` (`LocalDirectory` and the token mint),
   `services/mail.py` (`none` provider), `services/audit_log.py` (with the sink's
   `remove_member` mapping to disable-and-revoke, plan §4.10), `routers/auth.py`
   (`/api/auth/login | logout | forgot | token-state | welcome | reset | change-password |
   heartbeat`, all POST bodies; no token in any path, the link carries it in the fragment), the
   silent per-account lock (answers 401) and the retuned address throttle (the only 429), with
   unknown usernames counted per folded name. Tests: cookie flags (`__Host-dfp_session; Secure`
   in production, `dfp_session` without Secure when `APP_ENV` is anything else), the login round
   trip, the dummy-verify branch, lockout on a fake clock, the identical response sequence for
   a real and a non-existent username, the token state machine, no token in a route template or
   an access-log line, the password rules, the create paths' `EXISTS` refusal, the removal
   test, a GET that writes nothing.
4. **L5 Kit wiring** (plan §4.2, §4.6, §4.9; before L4, whose policy needs `can()`). `services/accounts.py` (the mapped store onto
   `account_levels`, `account_members`, `account_grants` and `brands` read-only; `init` with the
   owner from `ACCOUNT_OWNER` or the sentinel; `register_enforced` of the six permissions; the
   `IntegrityError` to `EXISTS` subclass), `services/identity.py`, `services/view_as.py` (act
   mode owner-only), `build_router(...)` mounted at `/api/bw` with every hook, the DFP
   `capabilities` block from DFP's own `GET /api/bw/me` registered before the kit router (plan
   §4.11), `GET /api/ops/impersonations` (owner), `GET /api/whoami` in `routers/auth.py` over
   `identity.optional_user` and `real_user` (the keys `test_view_as` asserts: `username`
   effective, `real_user`, `is_owner` of the effective user). For this one commit the kit
   router's write routes are listed in the stopgap's `OWNER_MUTATIONS` so
   `test_route_inventory.py` stays green; L4 deletes that dict. Tests: with `ACCOUNT_OWNER` unset and with it
   `REPLACE_WITH_USERNAME`, `is_owner("rian")` is false and `is_owner("!no-owner")` is false;
   `can()` false for a consumer with no member row; the owner may View As an active account
   that holds no member row.
5. **L4 Access policy** (plan §4.7; after L5). `services/access.py` replaces
   `services/mutations.py` and `services/owner_auth.py`: the class dicts, the SPA path sets,
   `MOUNTS`, `ROOT_FILES`, `safe_next()` and its fixture list, the pure `decide()` (`wants_html`
   from the route key, never `Accept`; the must-change gate of plan §4.4), the middleware, which
   takes `signed_in` and `active` from L3's session loader, `holds` from `services/accounts.can`
   and `read_only` from `services/view_as` (both L5) and emits `act_as.write` for every admitted
   non-safe request in act mode; `SITE_ACCESS` in `config.py` (default `members`); FastAPI docs off in
   production; `X-Robots-Tag` and the robots and machine-file answers while members-only; the
   server half of the stopgap retired (`owner_password` and `session_secret` in `config.py`,
   `services/owner_auth.py`, `routers/owner.py`, their lines in `main.py`,
   `tests/test_owner_auth.py`, the two `.app.env.example` lines); `OwnerGate.tsx`, `api/owner.ts`
   and their two importers stay until L7 and compile as they are, so between L4 and L7 the owner
   controls on `/plan` and `/discuss` answer 403, which is expected. `tests/test_route_inventory.py`
   rewritten over reads, pages, mounts and `App.tsx` with `tests/fixtures/open_routes.json`;
   `tests/test_owner_auth.py` becomes `tests/test_access.py` around the new `decide()`, with the
   traversal probes through the middleware, the `next` cases, the must-change gate, and an
   act-mode comment edit producing exactly one `act_as.write` row naming both ids (a read-only
   View As write none).
6. **L6 Authorship from the session** (plan §4.8). Every write that names a person reads the
   effective account; the payload `author` and `who` fields go; comment edit rules; the
   feature-comments GET, POST and edit route scoped by key prefix (`item:*` needs `plan.view`;
   the writes answer 404 without it). Tests: a comment posted as adam by rian in act mode is
   adam's row with an `act_as.write` row naming rian; an `admin` holder cannot edit another's
   comment; an `admin` holder's POST with an `item:*` key and edit of an `item:*` comment both
   answer 404, the GET omits `item:*` rows for them and returns them for the owner.
7. **L7 The SPA** (plan §4.11). `LoginPage`, `ForgotPage`, `SetPasswordPage`, `AccountPage`,
   `AdminPage` (the pack's panels under DFP tabs), the header account menu, `ViewAsBanner` and
   `ViewAsPicker`, `BwAuthProvider` as the one `/api/bw/me` fetch (refetch on focus and after
   writes, never on a timer), capability guards on `/plan`, `/sources`, curator mode and the
   client pages, the name boxes, `useCommenterName`, `dfp.commenter`, `OwnerGate.tsx`,
   `OwnerGate.css` and `api/owner.ts` removed with `useOwnerSession().data?.signed_in` in
   `pages/plan/ItemsTab.tsx` and `pages/DiscussionPage.tsx` replaced by the `plan_view`,
   `items_act` and `discussion_curate` capabilities from `useDfpMe()`, `LoginPage` and
   `SetPasswordPage` navigating only through `safeNext()` (the SPA never assigns `location`
   from a query value), `/welcome` and `/reset` reading the token from `location.hash`, the
   `--bw-*` tokens mapped, the 404 shell for a client on `/plan`. The typed client regenerates through `check.sh`.
   `test_house_style.py` walks the new files: no em dash, never "free".
8. **L8 Tests** (plan §4.12). The five conformance files byte-identical under `tests/kit/`;
   the contract's importables (`as_user`, `as_session`, `OWNER`, `HAS_INSTANCES`) in a new root
   `main/tests/conftest.py` because the files import `tests.conftest`; the fixtures (`client`,
   `kit`, `bw_calls` wrapping `LocalDirectory` under the kit's path names, the recording mailer,
   the store reset seeding the kit's `admin` and `member` plus the six stubbed central accounts)
   in `main/tests/kit/conftest.py` so no DFP pure test picks them up; DFP's own suites seed DFP's
   levels in their own fixture and reuse no kit basename; every test pure, about a second.
9. **L9 Docs** (plan §4.13). `main/docs/ACCOUNTS.md` (new, "Sources of truth" first),
   `ARCHITECTURE.md` steps 2 and 8, `CLIENT-SURFACES.md` comments and positioning rules,
   `RUNBOOK.md` ("Accounts": the CLI, seeding a host, the rollout order of plan §7, transfer of
   ownership, the one-worker note; "Owner sign-in" removed), `DATA-MODEL.md` regenerated,
   `agents.md` (replace the stopgap lines under "Decided 2026-09-04"; add the GET-write
   exception and the SQLite-in-tests clause; stay within 140 lines), `.app.env.example`,
   `main/CHANGELOG.md` Unreleased as one deploy with the before and after lists,
   `KICKOFFS.md`'s table, `.logs/planning/kit-upstream-proposals.md` (the six diffs and the
   scaffolder drift check). `python3 main/scripts/docmap.py --write` then `bash
   main/docs-check.sh --strict` at 0 fail.
10. **L10 The rehearsal and the handoff** (plan §7 steps 5 and 6). A local server over the
    built SPA against `dfp_r` (no `APP_ENV`, so production: the cookie is `__Host-dfp_session;
    Secure` on `http://127.0.0.1:8765`, which loopback accepts; a bare `dfp_session` there means
    the name was keyed on the scheme, which is wrong): every curl in the list with its expected
    answer, each POST carrying the Origin header of plan §4.4, the two traversal probes with
    `--path-as-is`, the `Set-Cookie` attributes read from the response, then the browser
    round trip (sign in, `/plan`, View As read-only refused a write, act mode, sign out; a
    `admin` account sees `/discuss` and not `/plan`; an anonymous window sees `/login`). Tear
    down `dfp_r`, the local server and the `main/static` symlink. The handoff names what rian
    sets, the deploy, every after-deploy command in order, and "green and ready".

## If time remains
- **L11** the Resend provider behind `MAIL_PROVIDER=resend` (plan §4.5), inert without its lines.
- The `login_attempts` table design note in `ACCOUNTS.md` for the multi-worker day.

## R2 (after 18 Sep; `/stream-r2`): discussions, plan §8, tasks T1 to T6 in that order.
## R3 (after 18 Sep; `/stream-r3`): collection oversight, plan §9, tasks V1 to V4; V4 is gated.

## Owns
`main/app/vendor/*`, `main/app/services/{accounts,identity,sessions,access,passwords,directory,
mail,audit_log,view_as}.py`, `main/app/routers/{auth,ops}.py`, `main/app/cli_accounts.py`,
`main/app/models/accounts.py`, the migration, `main/app/cli.py` (the `backfill` parser,
`cmd_backfill`, `BACKFILLS`, `backfill_accounts` and the `register_accounts(sub)` line),
`main/web/src/vendor/*`, `main/web/tsconfig.json` and `tsconfig.vendor.json`, the new pages and
the account menu, `main/scripts/vendor-check.sh` and `vendor-refresh.sh`, `main/tests/kit/*`,
`main/tests/conftest.py` and the new tests, `main/docs/ACCOUNTS.md`, and the lines it changes
in the files the tasks name.

## Must not touch
`docker-compose.yml` and anything under `deploy/` (Stream E); `.app.env`, `.env`, `.bw-auth.env`;
the collectors, `ingest.py`, `awards_import.py`, `seo.py` beyond the heads for the new pages and
the members-mode answers; the browser sidecar; the kit under `/srv/system/id-auth/` (propose,
never edit); `import/items.json` and `import/progress.json` except through their scripts.

## Ground rules
`.logs/planning/streams/OVERNIGHT-RULES.md` binds. Plus: never register a BW client, never
add a hostname, never mint `INDEXNOW_KEY`, never write a link or a password into a log or the
transcript, never run the server's app scaffolder, never edit a vendored byte. Security review
before code is plan §5; `srv-gw security-audit` runs when rian deploys, not in this session.
