RIFF · operator how-to · 2026-07-08 · siblings: org-scoped membership (the design), config cascade, session facts
Business Admin Access — panel, login & deploy handoff
Every business you deploy gets its own admin panel, its own login, and a one-page handoff doc — so a business owner can see their own callers and leads, and nobody else's. This is the operator surface on top of per-business identity scoping.
https://riff.chattychapters.com/admin
(locally: http://127.0.0.1:8767/admin), paste the business's login token, submit.
The token lives in the handoff doc the deploy wrote — read it with
cat docs/businesses/<scope>-admin.md (owner-only, e.g. armadillo_ink-admin.md).
The two questions everyone asks
| Question | Answer |
|---|---|
| Is the panel for all businesses, or just this one? | All of them. One /admin route, but each business logs in with its own
token and sees only its own scope's data. A business only has a token once it has been
deployed since this feature shipped — so today only ones you've (re)deployed have one. |
| Is the token generated on flow build? | No — on deploy. riff.business_pack build (pack → flow YAML)
touches nothing admin-related; deploy_business.py's final step mints the token and writes
the handoff doc. Every deploy rotates the token (a fresh one replaces the old — which
then stops working), so always use the newest handoff doc. |
How it works — three parts
- Scope — a business's identity key is
scope_for_flow(flow)= itsorg_idif it declares one, else itsflow_id(riff/effective_config.py). Same key the caller-identity store uses, so the panel and the phone agent agree on "who belongs to whom." - Token → scope login — no passwords.
riff/admin_tokens.pymints a randomriff_admin_…token, stores only its SHA-256 hash, and verifies withsecrets.compare_digest(constant-time). Login sets a signedHttpOnly/Secure/SameSite=Strictcookie carrying the scope. - Scoped panel — routes on the phone server (
riff/phone/server.py):/admin,/admin/login,/admin/logout. Every read filters by the cookie's scope, reusing the scoped store (ProfileStore.identities_for_phone,intake_sessions WHERE scope = ?). v0 is read-only.
What the admin sees
| Phone | Name | Verified | SMS | Used |
|---|---|---|---|---|
| +1 512-•••-1188 | Marisol Vega | yes | yes | ink_club · consultation |
| +1 737-•••-0455 | Devon Pratt | yes | no | ink_club |
| +1 512-•••-7196 | — new caller — | new | — | leave_message |
WHERE scope = 'armadillo_ink'. A caller
known at another business never appears here.web/_business-admin-mockup.html. Read-only in v0 (callers,
leads, message tickets, recent activity); config editing is a later slice.Use cases
Case A1 — Log in and see your own callers TA1
Deploy writes docs/businesses/<scope>-admin.md. The admin copies the
Login token, POSTs it to /admin/login, and lands on the panel showing that
business's callers/leads. A missing or wrong token → 401, no panel.
Test: tests/phone/test_business_admin.py::test_business_admin_login_401s_on_bad_or_absent_token.
Case A2 — Each business is isolated TA2
A cookie scoped to Business A can never read Business B's callers —
every query filters by the cookie's scope. Two businesses, two tokens, two disjoint views.
Test: test_business_admin_cookie_scope_cannot_read_other_business_callers.
Case A3 — The token is a deploy artifact, not a build artifact TA3
riff.business_pack build mints no token. Only
deploy_business.py's _write_admin_handoff step does — after gates, build,
route, restart, preflight. So a plain recompile leaves tokens untouched.
Case A4 — Redeploy rotates the token TA4
mint uses ON CONFLICT(scope) DO UPDATE, so each deploy replaces the token.
The previous token stops working — that is also the "rotate on leak" story: if a token leaks, just
redeploy and the old one is dead. Always use the newest handoff doc.
Case A5 — The secret is safe at rest and out of git TA5
The store keeps only the token's hash. The handoff file (which shows the plaintext once) is written
0o600 (owner-only) into a 0o700 dir, and docs/businesses/ is
.gitignored so a token never enters git history.
Tests: tests/test_admin_tokens.py::test_admin_token_plaintext_is_never_stored,
tests/test_deploy_admin_handoff_perms.py.
Code map — where each claim lives
| Concern | File | What |
|---|---|---|
| Scope key | riff/effective_config.py | scope_for_flow(flow) — org_id or flow_id |
| Token store | riff/admin_tokens.py | mint/verify/rotate/list; SHA-256 hash-only; constant-time verify |
| Panel + login | riff/phone/server.py | /admin, /admin/login, /admin/logout; signed scoped cookie; per-scope reads |
| Deploy handoff | scripts/deploy_business.py | _write_admin_handoff — mint + write docs/businesses/<scope>-admin.md (0o600) |
| Scoped data | riff/profile_store.py | identities_for_phone, scoped get_identity / intake_sessions |
| Mockup | web/_business-admin-mockup.html | the panel's visual reference |
Give another business its panel
Any deployed business can have one — just deploy it since the feature shipped:
.venv/bin/python scripts/deploy_business.py packs/<biz>.pack.yaml --did +1512... --yes # → prints: ADMIN HANDOFF: docs/businesses/<scope>-admin.md cat docs/businesses/<scope>-admin.md # copy the Login token, hit /admin
Related backlog: F-374 (this panel), F-376 (per-business
identity scoping it stands on), fix journal FX-027 (build) & FX-028
(handoff-token file permissions). Design rationale: org-scoped membership.