# 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: ```ts 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:` 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//`, 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/`) 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: - [ ] A setting that was never stored reads as its schema's defaults. - [ ] A write that fails the schema is refused, and a valid write is visible to `get` at once and notifies subscribers. - [ ] A stored value that fails its schema stops startup, naming the key. Bootstrap admins: - [ ] Startup fails when `ADMIN_DIDS` is empty or holds an invalid DID. - [ ] A DID in `ADMIN_DIDS` holds `admin`, has access whatever the registration mode says, and can't be removed through the API. Roles: - [ ] `rolesFor` returns `user`, plus every role whose explicit members, PDS hosts, or handle domains match. - [ ] A PDS host matches the host of the PDS URL exactly, and not a subdomain. - [ ] A handle domain matches the domain itself and its subdomains, and not a name that only ends with the same letters. - [ ] Adding a member by handle stores the handle's DID, and a handle that doesn't resolve is a 400. - [ ] A role change applies on the user's next request without a new login. - [ ] Creating a role with an invalid name is refused, and built-in roles can't be deleted. - [ ] Deleting a role named by access or an admin model is refused, listing what names it. Registration and access: - [ ] A fresh install is open, and anyone can create an account. - [ ] In `invite` mode, holders of an invite role and added people can create an account, and anyone else is turned away with the invite-only message naming their DID. - [ ] In `closed` mode, only added people and admins can create an account, and anyone else is turned away with the closed message. - [ ] A turned-away user's OAuth session is revoked and no account row is created. - [ ] An existing account keeps access when the mode changes and when it loses a role. - [ ] Creating an account deletes the added person's row. - [ ] `inviteRoles` naming a role that doesn't exist is refused. - [ ] The turn runner doesn't start a turn for a user without full access, and the sync scheduler and discovery skip them. Added users and suspension: - [ ] An admin adds a person by handle or DID, and adding someone who already has an account is refused. - [ ] An added person who hasn't signed in can be removed. - [ ] A suspended account gets 403 `AccessDenied` with the suspended message, never its reason, keeps its web session for shared chats, and gets no turns or sync. - [ ] A suspended user who signs in from the login page is turned away with the suspended message, and from a share link gets a viewer session. - [ ] Restoring an account gives it access again on its next request. - [ ] Admins can't be suspended, by an admin or a plugin. - [ ] `ctx.accounts.suspend` and `restore` change the account, record `plugin:`, and return whether an account changed. Viewers: - [ ] A user who may not create an account and signs in with a share path in `next` gets a viewer session and returns to the shared chat. - [ ] A viewer can open a chat shared with them and gets 403 `AccessDenied` from every other route except `/api/me` and logout. - [ ] A viewer sign-in creates no settings space and runs no sync. - [ ] `GET /api/me` reports `access: 'viewer'` for a viewer and `'full'` for a user with access. - [ ] A viewer who may now create an account is told to sign in again, and that sign-in runs the login hook and clears the flag. - [ ] A user with access who signs in through a share link gets a full session. Admin API and area: - [ ] Every `/api/admin` route returns 403 for a user who isn't an admin. - [ ] `GET /api/me` includes `admin`. - [ ] A write that fails validation returns 400 with the issue paths and messages. - [ ] The users list pages 50 at a time and filters by handle or DID prefix. - [ ] The admin area redirects non-admins to `/`, and the settings sidebar shows the Admin link only to admins. - [ ] The roles section adds and removes members, and saves PDS hosts and handle domains. - [ ] The access section saves the registration mode and invite roles. - [ ] The users section adds people, removes added people, and suspends and restores accounts. - [ ] The web app shows the access message in place of the chat app for viewers, and still opens shared chats. ## 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`