ATProto-based AI Assistant Chat interface.
scn-chat specs admin.md
26 kB · 287 lines
Markdown
at main

Admin #

Summary #

SCN Chat gets an admin area in the web app, and the database starts holding the settings that used to live in config.yml. This spec builds the foundation the other admin specs sit on: a store for admin settings, a built-in admin role seeded from the environment, the admin area itself, and user access control. Roles move from config.yml into the database. A role's members can be listed one by one, matched by the PDS they use or their handle's domain, or set by a plugin from an external membership system. A registration setting decides who can create an account: anyone, members of chosen roles, or only people an admin adds. Once someone has an account they keep it whatever the setting says, until an admin or a plugin suspends it. Anyone turned away is told why and given their DID to pass to an admin. Someone who signs in from a shared chat link gets a viewer session that can open shared chats and nothing else.

This is the first of three specs. [[admin-plugins]] moves plugins and admin models into the admin area, and [[admin-settings]] moves the remaining settings and removes config.yml.

Motivation #

Until now anyone with an atproto account could sign in, and a server had no way to limit that. SCN Chat is a community project, so servers differ: some are open to anyone, some only to a community's members, and some only to people an admin picks. Admin models and the admin's web search engine are gated by role either way, so an open server doesn't pay for strangers. Keeping roles as DID lists in config.yml doesn't scale either: every change means editing a file and restarting the server, and the admin has to find each person's DID by hand.

A community running its own PDS, or handing out handles under its own domain, wants all of its people let in without listing anyone. Explicit members cover everyone else. Some communities already decide membership elsewhere. SCN members apply through a public record and are approved by admins in HappyView, and the server should honor that without an admin copying each approval by hand. Plugins can therefore set a role's members from an outside list. Groups are otherwise kept in a plain internal schema for now. Once an atproto group model settles, likely on spaces, roles migrate to it, either directly or through a plugin.

Sharing must keep working on a closed server. A shared chat is read with the viewer's own credential, so the viewer has to sign in, and the owner shouldn't need the viewer to be a member of the server first.

Settings follow one rule, learned from Open WebUI, which takes defaults from environment variables and then stores them in its database, so later changes to the environment are silently ignored. Here each setting lives in exactly one place. Values the server needs before the database is open, and secrets that protect the database, stay in the environment. Everything an admin changes while the server runs lives in the database and is edited in the admin area.

Design #

Settings store #

A generic key-value table holds admin settings that are not plugin instances or models:

app_setting (key text primary key, value_json text, updated_at, updated_by)

Each key has a zod schema in apps/server/src/settings/schemas.ts. Values are validated on write and on read. A stored value that fails its schema fails loudly: startup stops, naming the key. A migration must convert stored values whenever a schema changes incompatibly. Adding a field with a default needs no migration, because the default applies on read.

apps/server/src/settings/store.ts exports a SettingsStore that loads every key at startup and keeps the current values in memory, since there is one server process. get(key) returns the current value, or the schema's defaults when nothing is stored. set(key, value, adminDid) validates, writes, updates the in-memory copy, and notifies subscribers, so code that needs to react to a change, such as a scheduler, can subscribe. Every write is logged at info with the key and the admin's DID, and never the value.

This spec adds one key, access. [[admin-settings]] adds the rest.

Bootstrap admins #

ADMIN_DIDS in .env is a comma-separated list of DIDs. It is required: startup fails when it is empty or holds an invalid DID, since otherwise nobody could ever reach the admin area. The listed DIDs always hold the admin role, always have access, and cannot be removed in the admin area, which shows them as coming from the environment. This keeps an admin from locking everyone out.

Roles #

Roles move from config.yml to the database, and the roles section of config.yml is removed.

role (name text primary key, description text, pds_hosts_json text, handle_domains_json text, created_at, updated_at)
role_member (role, did, added_at, added_by), primary key (role, did)
  • Built-in roles. user is implicit, held by everyone who has signed in, and is never stored. admin is created by the migration and cannot be deleted. Neither can be renamed.
  • Names. Role names match ^[a-z][a-z0-9-]*$ and cannot be changed after creation, since settings and models refer to roles by name.
  • Members. A role's members come from the sources below, and a DID holds the role if any of them matches:
    • Explicit members in role_member. The admin adds them by handle or DID, and the server resolves a handle to its DID when it is added. People who have never signed in can be added ahead of time. A plugin can change a role's rows with ctx.roles.syncMembers and ctx.roles.addMember.
    • PDS hosts in pds_hosts_json, a list of hostnames such as pds.commonscomputer.com. A DID matches when the host of its PDS URL, from its DID document, equals one of them exactly.
    • Handle domains in handle_domains_json, a list of domains such as commonscomputer.com. A DID matches when its verified handle is the domain or ends in . plus the domain. Only the owner of a domain can hand out handles under it, and the server only stores handles that resolve back to the same DID, so the match can't be claimed by someone outside the domain.
  • Deleting. Deleting a role is refused while access, any admin model, or an API key names it, and the error lists them.

apps/server/src/auth/roles.ts becomes a Roles service. rolesFor({ did, handle, pdsUrl }) returns user, admin when the DID is in ADMIN_DIDS, and every role whose explicit members, PDS hosts, or handle domains match. It reads the database on every call, so a change applies on the user's next request without a new login. The PDS URL and handle come from the account row, which sign-in updates, or from identity resolution during sign-in, before the row exists.

Roles from plugins #

A plugin can keep a role in step with an outside system by changing the role's members with ctx.roles.syncMembers and ctx.roles.addMember, from a [[cron]] handler and the signIn:before hook. Role checks only read the database, so they never call a plugin. [[scn-member-registry]] describes both.

Registration #

The access setting decides who can create an account:

access: {
  registration: 'open' | 'invite' | 'closed' // default 'open'
  inviteRoles: string[] // default [], used by 'invite'
}
Mode Who can create an account
open Anyone with an atproto account.
invite Anyone holding a role in inviteRoles, and anyone an admin added.
closed Only people an admin added.

Creating an account means a first full sign-in, which records the account and runs the login hook. Admins can always create one. Every name in inviteRoles must be an existing role, checked on save.

Access follows the account. A user has full access to the chat app when they are an admin, or have an account that is neither viewer-only nor suspended. Changing the mode, or a user losing a role, doesn't take access away from anyone who already has an account. Removing someone is a suspension.

Added users #

An admin adds a person by handle or DID, and that person can then create an account in any mode. That is what makes closed usable, and it's a shortcut in invite mode.

account_invite (did text primary key, added_by, added_at)

The row is deleted when the person creates their account. Adding a DID that already has a full account is refused, since they already have access. An added person who hasn't signed in yet can be removed again.

Suspension #

A suspended account keeps its data and its OAuth session but loses access, so the server stops acting for it. Its owner can still sign in to view chats shared with them. The account table gains suspended_at, suspended_by, and suspended_reason, all null when the account isn't suspended. suspended_by is an admin's DID, or plugin:<id> for a plugin.

  • Admins suspend and restore accounts on the Users page, with an optional reason.
  • Plugins call ctx.accounts.suspend(did, { reason }) and ctx.accounts.restore(did), so a plugin that tracks membership elsewhere can act on a revocation. Core doesn't suspend anyone by itself. Both return whether an account changed.
  • Admins can't be suspended, by an admin or a plugin, so nobody can lock the admins out. Remove the admin role first. Suspending someone who has no account is a no-op.
  • Reasons are admin notes. They are shown on the Users page and never to the suspended user, so they can hold internal details.
  • The Users page shows who suspended an account and why, and an admin can restore any suspension, including a plugin's. A plugin that still disagrees can suspend the account again.

Viewers #

Anyone can sign in to view a chat shared with them, whether or not they have access. A user who signs in without access, through a share link, becomes a viewer:

  • Shared routes. A viewer can reach GET /api/me, POST /api/logout, and the /api/shared routes from the sharing spec, and nothing else. The owner's PDS still decides whether they may see the chat.
  • No setup. A viewer's sign-in skips the login hook, so the server creates no settings space for them, doesn't register for their notifications, and never syncs, stores, or runs turns for them.
  • Account flag. The account table gains viewer_only, set when an account is created by a viewer sign-in, and cleared when they create a full account, which runs the login hook. A viewer who later may create an account is asked to sign in again, so the login hook always runs before they use the chat app.

Checking access #

Access is checked in three places:

  • Sign-in. GET /oauth/callback resolves the identity, as it does today, then decides before it creates or updates the account or a web session:
    • An admin, or an existing full account that isn't suspended, signs in as today.
    • Anyone else who may create an account under the current mode creates it: the sign-in clears viewer_only, runs the login hook, and deletes their account_invite row.
    • Anyone else whose next parameter is a share path, /shared/<ownerDid>/<skey>, signs in as a viewer and returns to the shared chat.
    • Anyone else has their new OAuth session revoked and goes back to the login page with a message. No account row is created for them.
  • Every /api request. The session middleware resolves the account, then checks access for every route a viewer can't reach. A user without full access gets 403 { error: 'AccessDenied', message }. Their web session is kept, so shared chats still open. The web app sends them from every route except shared chats to the login page, which shows the message with sign-in and sign-out buttons.
  • Background work. The turn runner doesn't start a turn for a user without full access, and logs the skip at info. The sync scheduler, discovery, and notification registration skip them the same way.

The messages, which name the user's DID so they can pass it to an admin:

Situation Message
Suspended "Your account has been suspended. Contact an admin for details."
A viewer who may now create an account "Sign in again to start using chat."
invite mode "This server is invite-only. Ask an admin to add you, and give them your DID: did:plc:..."
closed mode "This server isn't taking new accounts. Ask an admin to add you, and give them your DID: did:plc:..."

Admin checks #

requireAdmin middleware guards every /api/admin route and returns 403 { error: 'Forbidden' } when the user doesn't hold admin. GET /api/me gains admin: boolean, access, and accessMessage, the message a viewer sees, or null.

Admin API #

Route Purpose
GET /api/admin/users?q=&cursor= Accounts, 50 at a time, newest activity first. Each has the DID, handle, storage mode, whether it is a viewer, its suspension, roles, and each role's source, plus created and last-active times. q matches the start of a handle or DID.
POST /api/admin/users Add a person from { identifier }, a handle or DID, so they can create an account.
GET /api/admin/invites, DELETE /api/admin/invites/:did People added who haven't created an account yet, and removing one.
POST /api/admin/users/:did/suspend, POST /api/admin/users/:did/restore Suspend an account with an optional { reason }, or restore it.
GET /api/admin/roles Every role, with its description, members with their handles and who added them, PDS hosts, and handle domains. admin also lists the DIDs from the environment, marked as such.
POST /api/admin/roles Create a role from { name, description }.
PATCH /api/admin/roles/:name Change description, pdsHosts, or handleDomains. Hosts and domains must be valid hostnames.
DELETE /api/admin/roles/:name Delete a role, refused for built-in roles and roles still in use.
POST /api/admin/roles/:name/members Add a member from { identifier }, a handle or DID. A handle that doesn't resolve is a 400 naming it.
DELETE /api/admin/roles/:name/members/:did Remove an explicit member. Refused for DIDs from ADMIN_DIDS.
GET /api/admin/access, PUT /api/admin/access Read and change the access setting.

Writes are validated with zod, and a failure is a 400 with { error: 'InvalidRequest', issues: [{ path, message }] }, so forms can show each message next to its field. That shape is shared by every admin form in the admin specs.

Admin area #

The web app gets an admin area at /admin, visible only to admins. It reuses the settings layout: a sidebar in the same style as the chat list, linking back to the chats, to the account settings, and to each section, with only the current section shown. Non-admins who open an /admin route are redirected to /, by the server when it renders the page. The settings sidebar shows an "Admin" link for admins.

Sections in this spec:

  • Users (/admin/users, the default). A form to add a person by handle or DID, the list of people added who haven't signed in yet, a search box, and a list of accounts with their handle, DID, storage mode, roles, last activity, and suspension, with viewers marked, and a "Load more" button. Each account can add the user to a role, remove them from one they hold explicitly, and suspend them, after asking for an optional reason, or restore them. Roles that come from a PDS host or handle domain are shown with their source but can't be removed there.
  • Roles (/admin/roles). A list of the roles with their description, member count, PDS hosts, and handle domains, each opening the role's page, and a form that creates a role and opens its page. A role's page (/admin/roles/<name>) edits its description and the PDS hosts and handle domains, one per line, lists its explicit members with who added them and a remove button, has an input to add a member by handle or DID, and deletes the role once the admin confirms.
  • Access (/admin/access). The registration mode, a checkbox per role for inviteRoles, shown in invite mode, and a Save button.

The UI stays as bare as the rest of the web app.

Environment #

Variable Purpose
ADMIN_DIDS Comma-separated DIDs that always hold admin. Required.

Docs #

docs/architecture.md gains a section on the admin area, access and viewers, and the split between environment and database settings. docs/plugins.md and the plugins spec document ctx.roles. docs/deployment.md explains ADMIN_DIDS and that a new server is open until an admin changes the registration mode. docs/plugins.md and the plugins spec document ctx.accounts. The sharing spec notes that viewers don't need access.

Scope Boundaries #

  • No anonymous viewing. Viewers sign in, since the owner's PDS only grants reads to a user.
  • No invite codes or links, and no sign-up requests for admins to approve.
  • No suspending accounts automatically in core. A plugin can do it.
  • No per-role limits on usage, quotas, or billing.
  • No audit log table. Admin changes are written to the server log.
  • No built-in roles from atproto lists, follows, or external group services. A plugin can provide them with ctx.roles.
  • No separate admin permissions. Every admin can change everything.
  • No settings export or import.
  • No multi-process deployments. The settings store assumes one server process.

Edge Cases and Decisions #

  • ADMIN_DIDS is the one role grant in the environment, so a broken database state or a mistaken edit can't lock every admin out.
  • A new server is open, since servers run by different communities want different things, and an open server is what most people expect when they try it.
  • The mode only decides who can create an account. Access follows the account, so changing the mode never cuts anyone off, and a lost role doesn't either. Removing someone is an explicit suspension, by an admin or a plugin.
  • A plugin acts on a revocation elsewhere by suspending the account, on its own schedule. Vetoing access on every request instead would mean an outage of the plugin's backend either locked members out or let revoked people in.
  • Admins always have access and can't be suspended.
  • The suspended message is fixed. Sign-in errors travel in the login page's query string, so they end up in browser history and proxy logs, and a reason could hold internal details or text from a plugin's external system.
  • A suspended user keeps their OAuth session and their data. The server only stops acting for them, and they can still view chats shared with them.
  • A user turned away at sign-in has their OAuth session revoked, so the server never holds tokens for people it turned away. Viewers keep theirs, since reading a shared chat needs it.
  • The access check happens at the callback, not before the redirect, because only the callback proves which DID signed in.
  • Only a share path in next makes a viewer, so the login page never creates one.
  • A viewer who later may create an account must sign in again, so the login hook creates their settings space before they use the chat app.
  • PDS host matching uses the PDS from the DID document. Handle domains match the verified handle stored at each sign-in, so someone who moves to a handle outside the domain keeps the role until they next sign in.
  • Role names are fixed at creation, since other settings refer to them by name.
  • Admin error messages are shown to the admin as they are, so InvalidBody carries the message without a prefix, and its issues with the field paths.
  • The users list pages with an offset cursor. Accounts are few enough that keyset paging isn't worth it.
  • The user search matches the lowercased query against handles and DIDs with LIKE, escaping % and _.
  • Plugins store a role's members rather than being asked on every check, so a slow outside system never slows a request, and the admin area can show who holds a role. A plugin whose outside system is down leaves the stored members alone, so an outage doesn't lock members out, and revocations wait for it to end.
  • Plugins can't set admin's members, so a plugin can't hand out admin rights.
  • An added person's row is kept until they create an account, so adding someone in open mode, where they don't need it, does no harm.

Acceptance Criteria #

Settings store:

Bootstrap admins:

Roles:

Registration and access:

Added users and suspension:

Viewers:

Admin API and area:

Files #

  • apps/server/src/settings/store.ts, apps/server/src/settings/schemas.ts
  • apps/server/src/auth/roles.ts, apps/server/src/auth/access.ts, apps/server/src/auth/accounts.ts, apps/server/src/auth/invites.ts
  • packages/plugin-api/src/index.ts, packages/plugin-api/src/testing.ts, apps/server/src/plugins/host.ts
  • apps/server/src/auth/routes.ts, apps/server/src/auth/web-session.ts
  • apps/server/src/admin/routes.ts
  • apps/server/src/turns/runner.ts, apps/server/src/sync/scheduler.ts
  • apps/server/src/config.ts, apps/server/src/server.ts
  • apps/server/src/db/migrations/0007_admin.ts, apps/server/src/db/migrations/0009_registration.ts
  • apps/web/src/routes/_app/admin.tsx, apps/web/src/routes/_app/admin/, apps/web/src/routes/_app.tsx, apps/web/src/routes/login.tsx
  • apps/web/src/features/admin/pages/AdminLayout.tsx, UsersAdmin.tsx, RolesAdmin.tsx, AccessAdmin.tsx, apps/web/src/features/admin/components/RolePicker.tsx, apps/web/src/features/admin/queries.ts, apps/web/src/features/settings/pages/SettingsLayout.tsx
  • .env.example, docs/architecture.md, docs/deployment.md, docs/plugins.md, specs/sharing.md, specs/plugins.md