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.

How do I get in? Open 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

QuestionAnswer
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

deploy_business.pymint(scope) + handoff admin_tokens.dbscope → token HASH(plaintext never stored) handoff doc 0o600<scope>-admin.md · token Admin pastes tokenPOST /admin/login scoped sessionsigned cookie = scope Panel — reads ONLY this scopeevery query: WHERE scope = cookie.scope
Deploy mints a token (only its hash is stored) and writes an owner-only handoff doc. The admin pastes that token, gets a signed scope-bound cookie, and the panel reads only that scope.
  1. Scope — a business's identity key is scope_for_flow(flow) = its org_id if it declares one, else its flow_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."
  2. Token → scope login — no passwords. riff/admin_tokens.py mints a random riff_admin_… token, stores only its SHA-256 hash, and verifies with secrets.compare_digest (constant-time). Login sets a signed HttpOnly/Secure/SameSite=Strict cookie carrying the scope.
  3. 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

Armadillo Ink
Tattoo studio · Austin, TX
scope: armadillo_ink
PhoneNameVerifiedSMSUsed
+1 512-•••-1188Marisol Vegayesyesink_club · consultation
+1 737-•••-0455Devon Prattyesnoink_club
+1 512-•••-7196— new caller —newleave_message
🔒 Isolated — this table only runs WHERE scope = 'armadillo_ink'. A caller known at another business never appears here.
Live mockup: 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

ConcernFileWhat
Scope keyriff/effective_config.pyscope_for_flow(flow) — org_id or flow_id
Token storeriff/admin_tokens.pymint/verify/rotate/list; SHA-256 hash-only; constant-time verify
Panel + loginriff/phone/server.py/admin, /admin/login, /admin/logout; signed scoped cookie; per-scope reads
Deploy handoffscripts/deploy_business.py_write_admin_handoff — mint + write docs/businesses/<scope>-admin.md (0o600)
Scoped datariff/profile_store.pyidentities_for_phone, scoped get_identity / intake_sessions
Mockupweb/_business-admin-mockup.htmlthe 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.