Accounts, invites & roles

Every act in verge-asm has an author. This guide covers who those authors are, how you add and remove them, and the exact line between the two roles — admin and viewer — that decides which acts each may perform.

Account management lives under Settings → Team (/settings?tab=team). The handlers are in cmd/web/settings.go (team surface) and cmd/web/auth.go (roles, invite acceptance, setup); the route table is cmd/web/handlers.go.


The two roles

There are exactly two roles, admin and viewerthere is no operator role. The role is a column on the account row, read live on every request, so a demotion or a removal takes effect on the target's very next click, not on their next sign-in.

  • admin performs every declared act — the writes that change what the estate is or how it is measured, each of which needs an author in the audit trail.
  • viewer is read-only across the product. A viewer reads every page but cannot change a single declared row.

What is admin-gated

An admin act is any handler mounted behind requireAdmin; a viewer hitting one is refused with 403. Concretely, admins alone may:

AreaAdmin-only act
ScopeDeclare seeds and exclusions, upload zone files, set custody, opt a scope into cold
VantagesProvision a prober (POST /probers)
ProposalsRun an org-name lookup, confirm a proposal into a seed, decline one
ScansTrigger a scan (POST /scans/trigger), finish onboarding
SourcesToggle a discovery source on or off (sources.md)
Port apertureEdit the verge-core frequency tier
SignalsDeclare or withdraw an annotation
ReportsDeclare a report schedule
DeliveryCreate/update/delete channels, set the retention dials
SSOAdd/edit/delete an identity provider, remove a binding (sso.md)
IntegrationsInstall or disconnect an integration
TeamInvite, change a role, require re-enrollment, remove an account

The whole Settings screen is itself admin-only (GET /settings is behind requireAdmin), so a viewer cannot even open the Team tab. This is stricter than a read surface like /sources, which a viewer can open but not toggle.

What a viewer may still do

Read-only is product-wide, but every account governs its own credentials. Through Profile (/profile, viewer-readable) any signed-in account may change its own password, enrol or hold its own two-factor, mint and revoke its own personal API tokens, link or unlink its own SSO identity, and end its own session. None of that is an admin act — it touches only the caller's own row.


Inviting a new account

New operators are added by invitation, not by an admin typing someone else's password. From Settings → Team, open the invite dialog and choose the role the new account will hold (admin or viewer) — that is the only field. An invite binds a role, never a username or an email; the invitee chooses their own username and password when they accept.

The invite lifecycle

  1. Mint — the admin submits the dialog (POST /settings/accounts). A single-use, high-entropy token is generated, stored only as its SHA-256 hash, and the plaintext join link is revealed once on the page. It is also written to the web logs (exactly as the setup and password-reset tokens are), because a self-hosted instance has no mail to send it through. The invite expires in 7 days.
  2. Deliver — hand the copied link to the new operator out of band. There is no in-product delivery.
  3. Accept — the invitee opens GET /invite?token=… (pre-auth; they hold only the token, no session), sees the role they are being granted, and sets a username and password (POST /invite). Usernames are up to 64 characters; passwords 8–72.
  4. Create & spend — a new account is created at the invite's role and the token is consumed, so the link is inert forever after. Acceptance grants no session: the new operator lands on /login with an "Account created — sign in with your new credentials" notice and signs in normally, then enrols two-factor on first sign-in. No privileged session is ever minted straight from a token.

A token that is missing, already spent, or past its 7 days renders an invitation invalid page rather than a form that would fail on submit.


Changing an account's role

From Settings → Team, open the change-role dialog on a member and save (POST /settings/accounts/role). The Save control stays disabled until the selected role actually differs, so a no-op never fires.

One invariant is enforced in code: you cannot demote the last admin. A change that would take the estate to zero admins — leaving every remaining account read-only and every mutation permanently locked out — is refused with "promote another account first." You cannot change your own role (a member never acts on their own row in Team); adjust your standing from another admin account.


Re-enrolling a lost second factor

When an operator loses their authenticator, an admin can force a fresh enrolment from Settings → Team → require re-enrollment (POST /settings/accounts/reenroll). This clears the member's TOTP secret and enabled flag: their current authenticator stops working immediately, and their next sign-in walks them through two-factor setup again. It touches neither their password nor any session — it is only a second-factor reset.

This is the admin-side recovery. The operator's own recovery codes are the self-service path that needs no admin at all — see authentication.md for the recovery-code flow and how re-enrolment issues a fresh set.


Removing an account

Removal is the most destructive Team act, so it is gated hardest. From Settings → Team open the remove dialog and type the member's exact username to confirm (POST /settings/accounts/remove). It is reached only through that dialog, never a menu click.

Three refusals protect the estate:

  • You cannot remove yourself.
  • You cannot remove the last admin (same invariant as demotion).
  • An account that authored attributed acts cannot be removed. Seeds, channels and other declared rows carry a created_by reference to their author; rather than orphan that work, the database refuses the delete and you are told to reassign or keep the account. In practice a brand-new account that has declared nothing can be removed cleanly; a working admin's account usually cannot, and should be demoted to viewer instead if you want to retire it while preserving its authorship.

There is no audit log of these acts — this build keeps no queryable admin-action feed (the Audit tab is honestly empty). The operational records that do exist are the delivery record and the message store.


How this meets the first-run admin and SSO

The first admin comes from /setup, not an invite. On a fresh instance with zero accounts, the single-use setup token (printed to the logs, or pinned with VERGE_SETUP_TOKEN) opens /setup, where you create the first account at role admin. Creating it closes the setup window — once any account exists, /setup redirects to /login and the token is spent. Every later account descends from that first admin through the invite flow above. See first-run.md and using.md.

SSO never creates or roles an account. An SSO identity is only an additional sign-in method bolted onto an account that already exists here. A verified identity with no binding is refused at sign-in, never provisioned — the user is told to sign in with a password and link the identity from their own Profile. So the role always lives on the local account, never on the identity provider, and inviting, re-roling, and removing are purely local-account acts regardless of how the operator signs in. Full detail is in sso.md.


Route reference

RouteMethodGatePurpose
/setupGET/POSTsetup tokenCreate the first admin; closes once any account exists
/settings?tab=teamGETadminThe Team management surface
/settings/accountsPOSTadminMint an invite at a role
/settings/accounts/rolePOSTadminChange an account's role (last-admin guard)
/settings/accounts/reenrollPOSTadminClear a member's second factor
/settings/accounts/removePOSTadminRemove an account (typed-name confirm, guards)
/inviteGET/POSTpre-auth tokenAccept an invitation; set username + password
/profileGETviewerSelf-service credentials, tokens, 2FA, SSO links