diff --git a/.dockerignore b/.dockerignore index 734e616..fde449a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,4 +3,3 @@ apps/web/dist data .env -config.yml diff --git a/.env.example b/.env.example index b80248c..9491324 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Secrets for SCN Chat. Public settings live in config.yml. +# SCN Chat bootstrap settings and secrets. NODE_ENV=development DATABASE_URL=sqlite:./data/scn-chat.sqlite @@ -9,12 +9,18 @@ OAUTH_PRIVATE_KEYS= # Retired secret keys, comma-separated, kept only to decrypt older values. SECRET_KEYS_OLD= -# Admin API keys, referenced from config.yml. -SCN_API_KEY= -COCORE_API_KEY= -ANTHROPIC_API_KEY= -OPENAI_API_KEY= -GOOGLE_API_KEY= +# Comma-separated hardcoded admin list. Required. +ADMIN_DIDS= -# Optional: point at a config file other than ./config.yml. -SCN_CHAT_CONFIG= +# The URL people reach the app at. HTTPS in production. In development it is the Vite server. +PUBLIC_URL=http://127.0.0.1:5173 +PORT=3000 +DATA_DIR=./data +LOG_LEVEL=info + +# permission-set, or raw to test the local fallback before the lexicons are published. +# Production refuses raw. +OAUTH_SCOPE_MODE=raw +PLC_URL=https://plc.directory +# Let the server reach PDSes and DID documents on private networks, for a local PDS. +ALLOW_PRIVATE_NETWORKS=false diff --git a/.gitignore b/.gitignore index 0deb3dd..5f706f8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,3 @@ coverage/ .DS_Store .claude/settings.local.json data/ -config.yml diff --git a/CLAUDE.md b/CLAUDE.md index da3048d..a86c0a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ An AI assistant chat app where each conversation is an atproto permissioned spac - `plugins/` holds the shipped plugins. - `docs/architecture.md` is the human-facing architecture overview, and `docs/plugins.md` the plugin author guide. Update them when a change affects what they describe. - `pnpm dev` runs the server and Vite together. `pnpm check` runs lint, type checking, and every test. `pnpm codegen` regenerates the lexicon code after a lexicon change, and `pnpm publish-lexicons ` publishes the lexicons from the lexicon authority account. `pnpm keys` prints fresh secrets. -- Local setup: copy `config.example.yml` to `config.yml` for public settings and plugins, and `.env.example` to `.env` for secrets, filled in from `pnpm keys`. Secrets never go in `config.yml`; it references them as `${NAME}`. +- Local setup: copy `.env.example` to `.env`, fill in the secrets from `pnpm keys`, and put your DID in `ADMIN_DIDS`. Plugins, models, roles, and every other runtime setting are configured in the admin area at `/admin`. - Server tests run on both SQLite and PGlite through `tests/helpers/db.ts`. `tests/helpers/fake-pds.ts` is a fake spaces PDS with real error codes, policies, and signed commits. ## The founding principle @@ -30,7 +30,8 @@ These are settled. Raise a concern before working against any of them. - The browser keeps its own local copy of the user's chats in SQLite compiled to WebAssembly, for the chat list, the open conversation, and search. That copy lives on the user's device, not on the server. - BYO API keys are stored encrypted in the app database, never in records. - Providers, tools, file ingesters, and turn hooks are plugins. Web search, fetch, and image generation are plugins, not core. -- The project is meant to be forked. Code refers to lexicon NSIDs only through one generated module, and app name, URLs, OAuth metadata, and admin models come from `config.yml`. +- The project is meant to be forked. Code refers to lexicon NSIDs only through one generated module. URLs and OAuth settings come from the environment, and the app name and admin models from the admin area. +- Each setting lives in exactly one place: the environment for what the server needs before its database is open, and the app database, edited in the admin area, for everything else. Never let both hold the same setting. ## Planned stack @@ -38,7 +39,7 @@ TypeScript throughout, as a pnpm workspace on Node 24 LTS, run natively with Nod Prefer existing libraries over writing our own, especially the official `@atproto/*` packages for anything protocol related. Dependencies must have licenses compatible with MIT. -Admins configure plugins, global models, roles, and every other public setting in `config.yml`, with secrets in `.env`. Roles gate which admin models a user may use. There is no admin UI yet. +Admins configure plugins, global models, roles, access, and every other runtime setting in the admin area, and changes apply without a restart. `.env` holds the secrets and the settings needed at startup, including `ADMIN_DIDS`, the admins no edit can remove. A registration mode decides who may create an account, and once someone has one they keep access until suspended. Roles gate invites, admin models, and admin-paid tools such as the admin's web search engine. Installing plugin packages stays outside the web UI: a plugin is a dependency of the root `package.json` with the `scn-chat-plugin` keyword. The web UI must stay barebones with minimal code and styling. A separate designer will build the real interface on top of it. diff --git a/README.md b/README.md index 1f50e0a..32dedb5 100644 --- a/README.md +++ b/README.md @@ -51,16 +51,15 @@ Phase 1: ### Setup 1. Install dependencies: `pnpm install` - 2. `cp .env.example .env` + 2. `cp .env.example .env`. 3. Run `pnpm keys` to generate secret keys. Copy `OAUTH_PRIVATE_KEYS` and `SECRET_KEY` into your `.env`. - 4. Set your AI inference provider API keys. - 5. Copy config.example.yml to config.yml. The defaults are setup for local development. - 6. Configure your supported AI models. + 4. Put your DID in `ADMIN_DIDS` so you can login and configure the application. ### Start the app 1. Run `pnpm dev` 2. Open http://127.0.0.1:5173 and sign in with your ATProto handle. + 3. Open Settings > Admin to add your plugins and models, and choose who else can sign in. ## Deploying diff --git a/apps/server/package.json b/apps/server/package.json index 998a3ab..4c19cbf 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -38,7 +38,6 @@ "pg": "8.23.0", "pino": "10.3.1", "undici": "8.11.2", - "yaml": "2.9.1", "zod": "4.6.5" }, "devDependencies": { diff --git a/apps/server/src/admin/plugin-routes.ts b/apps/server/src/admin/plugin-routes.ts new file mode 100644 index 0000000..6f58c77 --- /dev/null +++ b/apps/server/src/admin/plugin-routes.ts @@ -0,0 +1,149 @@ +import { type Context, Hono } from 'hono' +import { z } from 'zod' +import type { Roles } from '../auth/roles.ts' +import { signedInUser } from '../auth/routes.ts' +import { InvalidBody, jsonBody } from '../body.ts' +import type { Db } from '../db/index.ts' +import type { AppEnv } from '../env.ts' +import type { Logger } from '../logger.ts' +import { InstanceNotFound, type PluginAdmin } from '../plugins/admin.ts' +import type { PluginRuntime } from '../plugins/runtime.ts' +import { + adminModelIssues, + adminModelSchema, + deleteAdminModel, + listAdminModels, + reorderAdminModels, + saveAdminModel, + servesAdminModels, +} from '../providers/admin-models.ts' +import { safeErrorMessage } from '../safe-error.ts' +import { jsonObject } from '../schemas.ts' + +export type PluginAdminRoutesDeps = { + db: Db + roles: Roles + plugins: PluginAdmin + runtime: () => PluginRuntime + logger: Logger +} + +const modelRefs = z.array(z.object({ provider: z.string().min(1), id: z.string().min(1) })) + +const notFound = { error: 'NotFound', message: 'Plugin instance not found' } as const + +/** Plugin instances and admin models, for admins. */ +export function pluginAdminRoutes(deps: PluginAdminRoutesDeps) { + const found = async (work: () => Promise) => { + try { + return { ok: true as const, value: await work() } + } catch (err) { + if (err instanceof InstanceNotFound) return { ok: false as const } + throw err + } + } + + /** Read a model from the body and check it against the loaded providers and existing roles. */ + const checkedModel = async (c: Context) => { + const model = await jsonBody(c, adminModelSchema) + const issues = adminModelIssues(model, deps.runtime().host.providers, await deps.roles.names()) + if (issues.length) + throw new InvalidBody(issues.map((issue) => issue.message).join('; '), issues) + return model + } + + const modelExists = async (provider: string, id: string) => + (await listAdminModels(deps.db)).some((m) => m.provider === provider && m.id === id) + + return new Hono() + .get('/plugins/installed', (c) => c.json({ plugins: deps.plugins.installed() })) + .get('/plugins', async (c) => c.json({ instances: await deps.plugins.list() })) + .post('/plugins', async (c) => { + const body = await jsonBody( + c, + z.object({ package: z.string().min(1), options: jsonObject.default({}) }), + ) + const id = await deps.plugins.add(body.package, body.options, signedInUser(c).did) + return c.json({ id }, 201) + }) + .post('/plugins/list-models', async (c) => { + const body = await jsonBody( + c, + z.object({ + package: z.string().min(1), + instanceId: z.string().optional(), + options: jsonObject.default({}), + }), + ) + try { + return c.json({ + models: await deps.plugins.listModels(body.package, body.instanceId, body.options), + }) + } catch (err) { + if (err instanceof InvalidBody) throw err + deps.logger.warn({ err, package: body.package }, 'listing models for an admin failed') + return c.json({ error: 'UpstreamFailure', message: safeErrorMessage(err) }, 502) + } + }) + .put('/plugins/order', async (c) => { + const body = await jsonBody(c, z.object({ ids: z.array(z.string()) })) + await deps.plugins.reorder(body.ids, signedInUser(c).did) + return c.json({ ok: true }) + }) + .put('/plugins/:id', async (c) => { + const body = await jsonBody( + c, + z.object({ + options: jsonObject.optional(), + clearSecrets: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + }), + ) + const result = await found(() => + deps.plugins.update(c.req.param('id'), body, signedInUser(c).did), + ) + return result.ok ? c.json({ ok: true }) : c.json(notFound, 404) + }) + .delete('/plugins/:id', async (c) => { + const result = await found(() => deps.plugins.remove(c.req.param('id'), signedInUser(c).did)) + return result.ok ? c.json({ ok: true }) : c.json(notFound, 404) + }) + .get('/models', async (c) => { + const providers = deps.runtime().host.providers + const models = (await listAdminModels(deps.db)).map((model) => ({ + ...model, + warning: servesAdminModels(providers, model.provider) + ? null + : `Provider "${model.provider}" isn't loaded with an admin key, so users can't use this model.`, + })) + return c.json({ models }) + }) + .post('/models', async (c) => { + const model = await checkedModel(c) + if (await modelExists(model.provider, model.id)) + throw new InvalidBody(`${model.provider}/${model.id} is already an admin model`, [ + { path: ['id'], message: 'already exists' }, + ]) + await saveAdminModel(deps.db, model, signedInUser(c).did) + return c.json({ ok: true }, 201) + }) + .put('/models', async (c) => { + const model = await checkedModel(c) + if (!(await modelExists(model.provider, model.id))) + return c.json({ error: 'NotFound', message: 'Model not found' }, 404) + await saveAdminModel(deps.db, model, signedInUser(c).did) + return c.json({ ok: true }) + }) + .delete('/models', async (c) => { + const ref = await jsonBody(c, z.object({ provider: z.string(), id: z.string() })) + if (!(await deleteAdminModel(deps.db, ref.provider, ref.id))) + return c.json({ error: 'NotFound', message: 'Model not found' }, 404) + deps.logger.info({ admin: signedInUser(c).did, model: ref }, 'admin model removed') + return c.json({ ok: true }) + }) + .put('/models/order', async (c) => { + const body = await jsonBody(c, z.object({ models: modelRefs })) + await reorderAdminModels(deps.db, body.models) + return c.json({ ok: true }) + }) +} diff --git a/apps/server/src/admin/routes.ts b/apps/server/src/admin/routes.ts new file mode 100644 index 0000000..1fee0d9 --- /dev/null +++ b/apps/server/src/admin/routes.ts @@ -0,0 +1,298 @@ +import { isValidDid } from '@atproto/syntax' +import { Hono } from 'hono' +import { sql } from 'kysely' +import { z } from 'zod' +import { type Access, CannotSuspendAdmin } from '../auth/access.ts' +import { getAccount } from '../auth/accounts.ts' +import type { IdentityResolver } from '../auth/identity.ts' +import { addInvite, listInvites, removeInvite } from '../auth/invites.ts' +import { + addMember, + createRole, + deleteRole, + listRoles, + RoleExists, + removeMember, + roleExists, + updateRole, +} from '../auth/role-store.ts' +import { ADMIN_ROLE, IMPLICIT_ROLE } from '../auth/roles.ts' +import { requireAdmin, signedInUser } from '../auth/routes.ts' +import { InvalidBody, jsonBody } from '../body.ts' +import type { AppEnv } from '../env.ts' +import type { SettingsStore } from '../settings/store.ts' +import { type PluginAdminRoutesDeps, pluginAdminRoutes } from './plugin-routes.ts' +import { settingsAdminRoutes } from './settings-routes.ts' + +export type AdminRoutesDeps = PluginAdminRoutesDeps & { + access: Access + settings: SettingsStore + identity: IdentityResolver +} + +const PAGE_SIZE = 50 +const ROLE_NAME = /^[a-z][a-z0-9-]*$/ +const HOSTNAME = + /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/ + +const hostnames = z.array( + z.string().trim().toLowerCase().regex(HOSTNAME, 'must be a hostname, such as example.com'), +) + +const newRole = z.object({ + name: z.string().regex(ROLE_NAME, `must match ${ROLE_NAME}`), + description: z.string().default(''), +}) + +const rolePatch = z.object({ + description: z.string().optional(), + pdsHosts: hostnames.optional(), + handleDomains: hostnames.optional(), +}) + +const escapeLike = (value: string) => value.replace(/[\\%_]/g, (char) => `\\${char}`) + +const notFound = (what: string) => ({ error: 'NotFound', message: `${what} not found` }) as const + +/** What still names a role, so deleting it would leave a dangling reference. */ +async function roleUses(deps: AdminRoutesDeps, name: string): Promise { + const uses: string[] = [] + if (deps.settings.get('access').inviteRoles.includes(name)) uses.push('access') + const models = await deps.db + .selectFrom('admin_model') + .select(['provider', 'model_id', 'roles_json']) + .execute() + for (const model of models) { + if ((JSON.parse(model.roles_json) as string[]).includes(name)) + uses.push(`model ${model.provider}/${model.model_id}`) + } + return uses +} + +/** Turn a handle or DID into a DID, or fail naming what didn't resolve. */ +async function resolveIdentifier(deps: AdminRoutesDeps, identifier: string): Promise { + const trimmed = identifier.trim().replace(/^@/, '') + if (trimmed.startsWith('did:')) { + if (!isValidDid(trimmed)) throw new InvalidBody(`"${trimmed}" is not a valid DID`) + return trimmed + } + const did = await deps.identity.resolveHandle(trimmed).catch((err: unknown) => { + deps.logger.warn({ err, handle: trimmed }, 'resolving a handle for a role member failed') + return null + }) + if (!did) throw new InvalidBody(`The handle "${trimmed}" doesn't resolve to an account`) + return did +} + +/** Users, roles, and access. */ +function peopleRoutes(deps: AdminRoutesDeps) { + return new Hono() + .get('/users', async (c) => { + const q = c.req.query('q')?.trim() + const offset = Number(c.req.query('cursor') ?? 0) || 0 + let query = deps.db + .selectFrom('account') + .select([ + 'did', + 'handle', + 'pds_url', + 'storage_mode', + 'viewer_only', + 'suspended_at', + 'suspended_by', + 'suspended_reason', + 'created_at', + 'last_active_at', + ]) + .orderBy('last_active_at', 'desc') + .orderBy('did') + .limit(PAGE_SIZE + 1) + .offset(offset) + if (q) { + const prefix = `${escapeLike(q.toLowerCase())}%` + query = query.where( + sql`(handle like ${prefix} escape '\\' or did like ${prefix} escape '\\')`, + ) + } + const rows = await query.execute() + const users = await Promise.all( + rows.slice(0, PAGE_SIZE).map(async (row) => ({ + did: row.did, + handle: row.handle, + storageMode: row.storage_mode, + viewerOnly: row.viewer_only === 1, + suspension: row.suspended_at + ? { at: row.suspended_at, by: row.suspended_by, reason: row.suspended_reason } + : null, + createdAt: row.created_at, + lastActiveAt: row.last_active_at, + roles: await deps.roles.grantsFor({ + did: row.did, + handle: row.handle, + pdsUrl: row.pds_url, + }), + })), + ) + return c.json({ + users, + cursor: rows.length > PAGE_SIZE ? String(offset + PAGE_SIZE) : null, + }) + }) + .post('/users', async (c) => { + const body = await jsonBody(c, z.object({ identifier: z.string().min(1) })) + const did = await resolveIdentifier(deps, body.identifier) + const account = await getAccount(deps.db, did) + if (account && !account.viewerOnly) + throw new InvalidBody(`${account.handle ?? did} already has an account`) + const admin = signedInUser(c).did + await addInvite(deps.db, did, admin) + deps.logger.info({ admin, did }, 'user added') + return c.json({ did }, 201) + }) + .get('/invites', async (c) => { + const invites = await listInvites(deps.db) + const dids = invites.map((invite) => invite.did) + const handles = new Map( + (dids.length + ? await deps.db + .selectFrom('account') + .select(['did', 'handle']) + .where('did', 'in', dids) + .execute() + : [] + ).map((row) => [row.did, row.handle]), + ) + return c.json({ + invites: invites.map((invite) => ({ ...invite, handle: handles.get(invite.did) ?? null })), + }) + }) + .delete('/invites/:did', async (c) => { + if (!(await removeInvite(deps.db, c.req.param('did')))) return c.json(notFound('Invite'), 404) + return c.json({ ok: true }) + }) + .post('/users/:did/suspend', async (c) => { + const did = c.req.param('did') + const body = await jsonBody(c, z.object({ reason: z.string().max(500).optional() }), { + optional: true, + }) + if (!(await getAccount(deps.db, did))) return c.json(notFound('Account'), 404) + try { + await deps.access.suspend(did, signedInUser(c).did, body.reason) + } catch (err) { + if (err instanceof CannotSuspendAdmin) throw new InvalidBody(err.message) + throw err + } + return c.json({ ok: true }) + }) + .post('/users/:did/restore', async (c) => { + const did = c.req.param('did') + if (!(await getAccount(deps.db, did))) return c.json(notFound('Account'), 404) + await deps.access.restore(did, signedInUser(c).did) + return c.json({ ok: true }) + }) + .get('/roles', async (c) => { + const roles = await listRoles(deps.db) + const dids = [...new Set(roles.flatMap((role) => role.members.map((m) => m.did)))] + const handles = new Map( + (dids.length + ? await deps.db + .selectFrom('account') + .select(['did', 'handle']) + .where('did', 'in', dids) + .execute() + : [] + ).map((row) => [row.did, row.handle]), + ) + return c.json({ + roles: roles.map((role) => ({ + ...role, + members: role.members.map((member) => ({ + ...member, + handle: handles.get(member.did) ?? null, + })), + })), + environmentAdmins: [...deps.roles.environmentAdmins()], + }) + }) + .post('/roles', async (c) => { + const body = await jsonBody(c, newRole) + if (body.name === IMPLICIT_ROLE) + throw new InvalidBody(`"${IMPLICIT_ROLE}" is built in and held by everyone`, [ + { path: ['name'], message: 'is built in' }, + ]) + try { + await createRole(deps.db, body) + } catch (err) { + if (err instanceof RoleExists) + throw new InvalidBody(err.message, [{ path: ['name'], message: 'already exists' }]) + throw err + } + deps.logger.info({ admin: signedInUser(c).did, role: body.name }, 'role created') + return c.json({ ok: true }, 201) + }) + .patch('/roles/:name', async (c) => { + const name = c.req.param('name') + const body = await jsonBody(c, rolePatch) + if (!(await updateRole(deps.db, name, body))) return c.json(notFound('Role'), 404) + deps.logger.info({ admin: signedInUser(c).did, role: name }, 'role changed') + return c.json({ ok: true }) + }) + .delete('/roles/:name', async (c) => { + const name = c.req.param('name') + if (name === ADMIN_ROLE || name === IMPLICIT_ROLE) + throw new InvalidBody(`The "${name}" role is built in and can't be deleted`) + if (!(await roleExists(deps.db, name))) return c.json(notFound('Role'), 404) + const uses = await roleUses(deps, name) + if (uses.length) + throw new InvalidBody(`The "${name}" role is still used by: ${uses.join(', ')}`) + await deleteRole(deps.db, name) + deps.logger.info({ admin: signedInUser(c).did, role: name }, 'role deleted') + return c.json({ ok: true }) + }) + .post('/roles/:name/members', async (c) => { + const name = c.req.param('name') + const body = await jsonBody(c, z.object({ identifier: z.string().min(1) })) + if (!(await roleExists(deps.db, name))) return c.json(notFound('Role'), 404) + const did = await resolveIdentifier(deps, body.identifier) + const admin = signedInUser(c).did + await addMember(deps.db, name, did, admin) + deps.logger.info({ admin, role: name, did }, 'role member added') + return c.json({ did }, 201) + }) + .delete('/roles/:name/members/:did', async (c) => { + const { name, did } = c.req.param() + if (name === ADMIN_ROLE && deps.roles.isEnvironmentAdmin(did)) + throw new InvalidBody( + 'Admins listed in ADMIN_DIDS can only be removed from the environment', + ) + if (!(await removeMember(deps.db, name, did))) return c.json(notFound('Member'), 404) + deps.logger.info({ admin: signedInUser(c).did, role: name, did }, 'role member removed') + return c.json({ ok: true }) + }) + .get('/access', (c) => c.json(deps.settings.get('access'))) + .put('/access', async (c) => { + const body = await jsonBody( + c, + z.object({ + registration: z.enum(['open', 'invite', 'closed']), + inviteRoles: z.array(z.string()).default([]), + }), + ) + const known = await deps.roles.names() + const unknown = body.inviteRoles.filter((role) => !known.has(role)) + if (unknown.length) + throw new InvalidBody(`No such role: ${unknown.join(', ')}`, [ + { path: ['inviteRoles'], message: `No such role: ${unknown.join(', ')}` }, + ]) + return c.json(await deps.settings.set('access', body, signedInUser(c).did)) + }) +} + +/** Everything in the admin area, for admins only. */ +export function adminRoutes(deps: AdminRoutesDeps) { + return new Hono() + .use(requireAdmin) + .route('/', peopleRoutes(deps)) + .route('/', pluginAdminRoutes(deps)) + .route('/', settingsAdminRoutes(deps)) +} diff --git a/apps/server/src/admin/settings-routes.ts b/apps/server/src/admin/settings-routes.ts new file mode 100644 index 0000000..fec2cd6 --- /dev/null +++ b/apps/server/src/admin/settings-routes.ts @@ -0,0 +1,37 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import { signedInUser } from '../auth/routes.ts' +import { jsonBody } from '../body.ts' +import type { AppEnv } from '../env.ts' +import { jsonObject } from '../schemas.ts' +import { type SettingKey, settingSchemas } from '../settings/schemas.ts' +import type { SettingsStore } from '../settings/store.ts' + +/** Settings edited through generated forms. Access has its own routes. */ +export const FORM_SETTINGS = ['general', 'sessions', 'turns', 'sync'] as const + +type FormSetting = (typeof FORM_SETTINGS)[number] + +const isFormSetting = (key: string): key is FormSetting => + (FORM_SETTINGS as readonly string[]).includes(key) + +/** The app's own settings, as forms. */ +export function settingsAdminRoutes(deps: { settings: SettingsStore }) { + return new Hono() + .get('/settings', (c) => + c.json({ + settings: FORM_SETTINGS.map((key) => ({ + key, + schema: z.toJSONSchema(settingSchemas[key], { io: 'input' }) as Record, + value: deps.settings.get(key as SettingKey) as Record, + })), + }), + ) + .put('/settings/:key', async (c) => { + const key = c.req.param('key') + if (!isFormSetting(key)) return c.json({ error: 'NotFound', message: 'No such setting' }, 404) + const body = await jsonBody(c, jsonObject) + const value = await deps.settings.set(key, body, signedInUser(c).did) + return c.json({ value: value as Record }) + }) +} diff --git a/apps/server/src/api-types.ts b/apps/server/src/api-types.ts index 4f6d204..133a27a 100644 --- a/apps/server/src/api-types.ts +++ b/apps/server/src/api-types.ts @@ -1,3 +1,4 @@ +import type { adminRoutes } from './admin/routes.ts' import type { authApiRoutes } from './auth/routes.ts' import type { blobRoutes } from './blobs/routes.ts' import type { pluginRoutes } from './plugins/routes.ts' @@ -6,7 +7,7 @@ import type { sharingRoutes } from './sharing/routes.ts' import type { storageRoutes } from './storage/routes.ts' import type { turnRoutes } from './turns/routes.ts' -/** Route types for the api client, mounted under /api, with plugins under /api/plugins. */ +/** Route types for the api client, mounted under /api, with plugins under /api/plugins and admin under /api/admin. */ export type AuthApi = ReturnType export type StorageApi = ReturnType export type TurnsApi = ReturnType @@ -14,3 +15,4 @@ export type ProvidersApi = ReturnType export type PluginsApi = ReturnType export type BlobsApi = ReturnType export type SharingApi = ReturnType +export type AdminApi = ReturnType diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 8a90feb..89404ec 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -4,9 +4,11 @@ import { join } from 'node:path' import { serveStatic } from '@hono/node-server/serve-static' import { Hono } from 'hono' import { sql } from 'kysely' +import { type AdminRoutesDeps, adminRoutes } from './admin/routes.ts' import { SessionExpired } from './auth/pds.ts' import { type AuthDeps, + accessGate, authApiRoutes, oauthRoutes, originCheck, @@ -23,6 +25,7 @@ import { type PluginRoutesDeps, pluginRoutes } from './plugins/routes.ts' import { ModelUnavailable } from './providers/catalog.ts' import { type ProviderRoutesDeps, providerRoutes } from './providers/routes.ts' import { safeErrorMessage } from './safe-error.ts' +import { SettingsStore } from './settings/store.ts' import { sharingRoutes } from './sharing/routes.ts' import type { SharingService } from './sharing/service.ts' import { @@ -41,6 +44,8 @@ export type AppDeps = { config: Config db: Db logger: Logger + /** Admin settings. Without them the defaults apply. */ + settings?: SettingsStore /** Built web app to serve, in production. */ webDist?: string auth?: AuthDeps @@ -51,10 +56,13 @@ export type AppDeps = { blobs?: BlobRoutesDeps sharing?: SharingService sync?: SyncRoutesDeps + admin?: AdminRoutesDeps } export function createApp(deps: AppDeps) { const { config, db, logger } = deps + const settings = deps.settings ?? deps.auth?.settings ?? new SettingsStore(db, logger) + const appName = () => settings.get('general').appName const app = new Hono() app.use('*', async (c, next) => { @@ -72,14 +80,14 @@ export function createApp(deps: AppDeps) { }) if (deps.auth) { - app.use('/api/*', sessionMiddleware(deps.auth), originCheck(config)) + app.use('/api/*', sessionMiddleware(deps.auth), originCheck(config), accessGate(deps.auth)) app.route('/', oauthRoutes(deps.auth)) } const api = new Hono().get('/health', async (c) => { try { await sql`select 1`.execute(db) - return c.json({ status: 'ok' as const, appName: config.appName }) + return c.json({ status: 'ok' as const, appName: appName() }) } catch (err) { logger.error({ err }, 'health check database query failed') return c.json({ status: 'error' as const }, 503) @@ -93,6 +101,7 @@ export function createApp(deps: AppDeps) { if (deps.turns) api.route('/', turnRoutes(deps.turns)) if (deps.blobs) api.route('/', blobRoutes(deps.blobs)) if (deps.sharing) api.route('/', sharingRoutes(deps.sharing)) + if (deps.admin) api.route('/admin', adminRoutes(deps.admin)) app.route('/api', api) if (deps.sync) app.route('/', syncRoutes(deps.sync)) @@ -107,7 +116,7 @@ export function createApp(deps: AppDeps) { return c.json({ error: 'InvalidStoredRecord', message: err.message }, 502) } if (err instanceof InvalidBody) - return c.json({ error: 'InvalidRequest', message: err.message }, 400) + return c.json({ error: 'InvalidRequest', message: err.message, issues: err.issues }, 400) if (err instanceof InvalidCursor) return c.json({ error: 'InvalidRequest', message: err.message }, 400) if (err instanceof InvalidSpaceUri) @@ -136,14 +145,12 @@ export function createApp(deps: AppDeps) { app.all('/api/*', (c) => c.json({ error: 'NotFound' }, 404)) if (deps.webDist) { - const index = renderIndexHtml( - readFileSync(join(deps.webDist, 'index.html'), 'utf8'), - config.appName, - ) - app.get('/', (c) => c.html(index)) - app.get('/index.html', (c) => c.html(index)) + const raw = readFileSync(join(deps.webDist, 'index.html'), 'utf8') + const index = () => renderIndexHtml(raw, appName()) + app.get('/', (c) => c.html(index())) + app.get('/index.html', (c) => c.html(index())) app.use('*', serveStatic({ root: deps.webDist })) - app.get('*', (c) => c.html(index)) + app.get('*', (c) => c.html(index())) } return app diff --git a/apps/server/src/auth/access.ts b/apps/server/src/auth/access.ts new file mode 100644 index 0000000..c3041b6 --- /dev/null +++ b/apps/server/src/auth/access.ts @@ -0,0 +1,96 @@ +import type { RoleIdentity } from '@scn-chat/plugin-api' +import type { Db } from '../db/index.ts' +import type { Logger } from '../logger.ts' +import type { SettingsStore } from '../settings/store.ts' +import { type Account, getAccount, restoreAccount, suspendAccount } from './accounts.ts' +import { isInvited } from './invites.ts' +import { ADMIN_ROLE, type Roles } from './roles.ts' + +export const inviteOnlyMessage = (did: string) => + `This server is invite-only. Ask an admin to add you, and give them your DID: ${did}` + +export const closedMessage = (did: string) => + `This server isn't taking new accounts. Ask an admin to add you, and give them your DID: ${did}` + +/** Shown to suspended users. The reason is an admin note and never part of it. */ +export const SUSPENDED_MESSAGE = 'Your account has been suspended. Contact an admin for details.' + +export const SIGN_IN_AGAIN_MESSAGE = 'Sign in again to start using chat.' + +export type AccessLevel = 'full' | 'viewer' + +export class CannotSuspendAdmin extends Error { + constructor(did: string) { + super(`${did} is an admin, and admins can't be suspended. Remove the admin role first.`) + this.name = 'CannotSuspendAdmin' + } +} + +export type AccessDeps = { db: Db; roles: Roles; settings: SettingsStore; logger: Logger } + +/** Who may create an account, who has access, and suspending accounts. */ +export class Access { + private readonly deps: AccessDeps + + constructor(deps: AccessDeps) { + this.deps = deps + } + + /** May someone with these roles create an account under the registration mode? */ + async mayRegister(did: string, roles: string[]): Promise { + if (roles.includes(ADMIN_ROLE)) return true + const { registration, inviteRoles } = this.deps.settings.get('access') + if (registration === 'open' || (await isInvited(this.deps.db, did))) return true + return registration === 'invite' && roles.some((role) => inviteRoles.includes(role)) + } + + /** Can this user sign in to the chat app, given their account, if any, and roles? */ + async allowsSignIn(did: string, account: Account | undefined, roles: string[]) { + if (roles.includes(ADMIN_ROLE)) return true + if (account?.suspension) return false + if (account && !account.viewerOnly) return true + return this.mayRegister(did, roles) + } + + /** The account's roles, and whether it may use the chat app or only view shared chats. */ + async forAccount(account: Account): Promise<{ roles: string[]; access: AccessLevel }> { + const roles = await this.deps.roles.rolesFor(account) + const full = roles.includes(ADMIN_ROLE) || (!account.viewerOnly && account.suspension === null) + return { roles, access: full ? 'full' : 'viewer' } + } + + /** Why a user can't use the chat app. */ + async deniedMessage(did: string, account: Account | undefined, roles: string[]) { + if (account?.suspension) return SUSPENDED_MESSAGE + if (await this.mayRegister(did, roles)) return SIGN_IN_AGAIN_MESSAGE + return this.deps.settings.get('access').registration === 'closed' + ? closedMessage(did) + : inviteOnlyMessage(did) + } + + /** The roles of a DID, using its account for the handle and PDS when it has one. */ + async rolesForDid(did: string): Promise { + const account = await getAccount(this.deps.db, did) + return this.deps.roles.rolesFor(account ?? ({ did, handle: null, pdsUrl: '' } as RoleIdentity)) + } + + /** May the server act for this DID in the background? */ + async hasAccess(did: string): Promise { + const account = await getAccount(this.deps.db, did) + return account ? (await this.forAccount(account)).access === 'full' : false + } + + /** Suspend an account, by an admin's DID or plugin:. Returns whether it changed. */ + async suspend(did: string, by: string, reason?: string): Promise { + if ((await this.rolesForDid(did)).includes(ADMIN_ROLE)) throw new CannotSuspendAdmin(did) + const changed = await suspendAccount(this.deps.db, did, by, reason) + if (changed) this.deps.logger.info({ did, by }, 'account suspended') + return changed + } + + async restore(did: string, by: string): Promise { + const changed = await restoreAccount(this.deps.db, did) + if (changed) this.deps.logger.info({ did, by }, 'account restored') + return changed + } +} diff --git a/apps/server/src/auth/accounts.ts b/apps/server/src/auth/accounts.ts index 8004d70..646c5ec 100644 --- a/apps/server/src/auth/accounts.ts +++ b/apps/server/src/auth/accounts.ts @@ -9,8 +9,14 @@ export type Account = { storageMode: StorageMode backgroundSync: boolean lastActiveAt: string + /** Signed in only to view shared chats, and never set up for the chat app. */ + viewerOnly: boolean + suspension: Suspension | null } +/** Who suspended an account, an admin's DID or plugin:, and why. */ +export type Suspension = { at: string; by: string; reason: string | null } + export class SpacesLostError extends Error { constructor() { super( @@ -27,6 +33,10 @@ type AccountRow = { storage_mode: StorageMode background_sync: number last_active_at: string + viewer_only: number + suspended_at: string | null + suspended_by: string | null + suspended_reason: string | null } function toAccount(row: AccountRow): Account { @@ -37,22 +47,46 @@ function toAccount(row: AccountRow): Account { storageMode: row.storage_mode, backgroundSync: row.background_sync === 1, lastActiveAt: row.last_active_at, + viewerOnly: row.viewer_only === 1, + suspension: row.suspended_at + ? { at: row.suspended_at, by: row.suspended_by ?? '', reason: row.suspended_reason } + : null, } } export async function getAccount(db: Db, did: string): Promise { const row = await db .selectFrom('account') - .select(['did', 'handle', 'pds_url', 'storage_mode', 'background_sync', 'last_active_at']) + .select([ + 'did', + 'handle', + 'pds_url', + 'storage_mode', + 'background_sync', + 'last_active_at', + 'viewer_only', + 'suspended_at', + 'suspended_by', + 'suspended_reason', + ]) .where('did', '=', did) .executeTakeFirst() return row ? toAccount(row) : undefined } -/** Record a login, choosing the storage mode for new accounts and keeping it for existing ones. */ +/** + * Record a login, choosing the storage mode for new accounts and keeping it for existing ones. + * A viewer login marks a new account viewer-only, and a full login clears the mark. + */ export async function recordLogin( db: Db, - login: { did: string; handle: string | null; pdsUrl: string; spacesAllowed: boolean }, + login: { + did: string + handle: string | null + pdsUrl: string + spacesAllowed: boolean + viewer?: boolean + }, now = new Date(), ): Promise { const existing = await getAccount(db, login.did) @@ -71,6 +105,7 @@ export async function recordLogin( created_at: at, last_login_at: at, last_active_at: at, + viewer_only: login.viewer ? 1 : 0, }) .onConflict((oc) => oc.column('did').doUpdateSet({ @@ -78,6 +113,7 @@ export async function recordLogin( pds_url: login.pdsUrl, last_login_at: at, last_active_at: at, + ...(login.viewer ? {} : { viewer_only: 0 }), }), ) .execute() @@ -103,3 +139,34 @@ export async function setBackgroundSync(db: Db, did: string, enabled: boolean): .where('did', '=', did) .execute() } + +/** Suspend an account that isn't suspended yet. Returns true if status changed. */ +export async function suspendAccount( + db: Db, + did: string, + by: string, + reason?: string, +): Promise { + const result = await db + .updateTable('account') + .set({ + suspended_at: new Date().toISOString(), + suspended_by: by, + suspended_reason: reason?.trim() || null, + }) + .where('did', '=', did) + .where('suspended_at', 'is', null) + .executeTakeFirst() + return result.numUpdatedRows > 0n +} + +/** Restore a suspended account. Returns whether it changed. */ +export async function restoreAccount(db: Db, did: string): Promise { + const result = await db + .updateTable('account') + .set({ suspended_at: null, suspended_by: null, suspended_reason: null }) + .where('did', '=', did) + .where('suspended_at', 'is not', null) + .executeTakeFirst() + return result.numUpdatedRows > 0n +} diff --git a/apps/server/src/auth/invites.ts b/apps/server/src/auth/invites.ts new file mode 100644 index 0000000..71a1354 --- /dev/null +++ b/apps/server/src/auth/invites.ts @@ -0,0 +1,29 @@ +import type { Db } from '../db/index.ts' + +/** People an admin added can create an account in any registration mode. */ +export type Invite = { did: string; addedBy: string; addedAt: string } + +export async function addInvite(db: Db, did: string, addedBy: string): Promise { + await db + .insertInto('account_invite') + .values({ did, added_by: addedBy, added_at: new Date().toISOString() }) + .onConflict((oc) => oc.column('did').doNothing()) + .execute() +} + +export async function isInvited(db: Db, did: string): Promise { + return Boolean( + await db.selectFrom('account_invite').select('did').where('did', '=', did).executeTakeFirst(), + ) +} + +export async function listInvites(db: Db): Promise { + const rows = await db.selectFrom('account_invite').selectAll().orderBy('added_at').execute() + return rows.map((row) => ({ did: row.did, addedBy: row.added_by, addedAt: row.added_at })) +} + +/** Returns false when the DID wasn't added. */ +export async function removeInvite(db: Db, did: string): Promise { + const result = await db.deleteFrom('account_invite').where('did', '=', did).executeTakeFirst() + return result.numDeletedRows > 0n +} diff --git a/apps/server/src/auth/oauth-client.ts b/apps/server/src/auth/oauth-client.ts index f5b5579..f713794 100644 --- a/apps/server/src/auth/oauth-client.ts +++ b/apps/server/src/auth/oauth-client.ts @@ -59,6 +59,7 @@ export async function createOAuthClient( config: Config, db: Db, scope: string, + appName: string, ): Promise { const redirectUri = `${config.publicUrl}/oauth/callback` as const const loopback = isLoopbackUrl(config.publicUrl) @@ -71,7 +72,7 @@ export async function createOAuthClient( ? buildAtprotoLoopbackClientMetadata({ redirect_uris: [redirectUri], scope }) : { client_id: `${config.publicUrl}/oauth-client-metadata.json`, - client_name: config.appName, + client_name: appName, client_uri: config.publicUrl, redirect_uris: [redirectUri] as [string, ...string[]], scope, diff --git a/apps/server/src/auth/role-store.ts b/apps/server/src/auth/role-store.ts new file mode 100644 index 0000000..132fa4e --- /dev/null +++ b/apps/server/src/auth/role-store.ts @@ -0,0 +1,103 @@ +import type { Db } from '../db/index.ts' +import { ADMIN_ROLE } from './roles.ts' + +export type RoleMember = { did: string; addedAt: string; addedBy: string } + +export type RoleSummary = { + name: string + description: string + builtIn: boolean + pdsHosts: string[] + handleDomains: string[] + members: RoleMember[] +} + +export class RoleExists extends Error { + constructor(name: string) { + super(`A role named "${name}" already exists`) + this.name = 'RoleExists' + } +} + +export async function listRoles(db: Db): Promise { + const [roles, members] = await Promise.all([ + db.selectFrom('role').selectAll().orderBy('created_at').orderBy('name').execute(), + db.selectFrom('role_member').selectAll().orderBy('added_at').execute(), + ]) + return roles.map((role) => ({ + name: role.name, + description: role.description, + builtIn: role.name === ADMIN_ROLE, + pdsHosts: JSON.parse(role.pds_hosts_json) as string[], + handleDomains: JSON.parse(role.handle_domains_json) as string[], + members: members + .filter((member) => member.role === role.name) + .map((member) => ({ did: member.did, addedAt: member.added_at, addedBy: member.added_by })), + })) +} + +export async function roleExists(db: Db, name: string): Promise { + return Boolean( + await db.selectFrom('role').select('name').where('name', '=', name).executeTakeFirst(), + ) +} + +export async function createRole(db: Db, role: { name: string; description: string }) { + if (await roleExists(db, role.name)) throw new RoleExists(role.name) + const now = new Date().toISOString() + await db + .insertInto('role') + .values({ + name: role.name, + description: role.description, + pds_hosts_json: '[]', + handle_domains_json: '[]', + created_at: now, + updated_at: now, + }) + .execute() +} + +/** Change a role's description or matching rules. Returns false when there is no such role. */ +export async function updateRole( + db: Db, + name: string, + patch: { description?: string; pdsHosts?: string[]; handleDomains?: string[] }, +): Promise { + const result = await db + .updateTable('role') + .set({ + ...(patch.description !== undefined ? { description: patch.description } : {}), + ...(patch.pdsHosts ? { pds_hosts_json: JSON.stringify(patch.pdsHosts) } : {}), + ...(patch.handleDomains ? { handle_domains_json: JSON.stringify(patch.handleDomains) } : {}), + updated_at: new Date().toISOString(), + }) + .where('name', '=', name) + .executeTakeFirst() + return result.numUpdatedRows > 0n +} + +export async function deleteRole(db: Db, name: string): Promise { + await db.transaction().execute(async (tx) => { + await tx.deleteFrom('role_member').where('role', '=', name).execute() + await tx.deleteFrom('role').where('name', '=', name).execute() + }) +} + +export async function addMember(db: Db, role: string, did: string, addedBy: string) { + await db + .insertInto('role_member') + .values({ role, did, added_at: new Date().toISOString(), added_by: addedBy }) + .onConflict((oc) => oc.columns(['role', 'did']).doNothing()) + .execute() +} + +/** Returns false when the DID was not an explicit member. */ +export async function removeMember(db: Db, role: string, did: string): Promise { + const result = await db + .deleteFrom('role_member') + .where('role', '=', role) + .where('did', '=', did) + .executeTakeFirst() + return result.numDeletedRows > 0n +} diff --git a/apps/server/src/auth/roles.ts b/apps/server/src/auth/roles.ts index ba53d27..1c093f5 100644 --- a/apps/server/src/auth/roles.ts +++ b/apps/server/src/auth/roles.ts @@ -1,38 +1,111 @@ -import { isValidDid } from '@atproto/syntax' +import type { Logger, RoleIdentity, RoleSource } from '@scn-chat/plugin-api' +import type { Db } from '../db/index.ts' export const IMPLICIT_ROLE = 'user' -const ROLE_PATTERN = /^[a-z][a-z0-9-]*$/ +export const ADMIN_ROLE = 'admin' -export class RolesConfigError extends Error { - constructor(message: string) { - super(message) - this.name = 'RolesConfigError' - } +/** Where a role came from: the environment, an explicit membership, a PDS host, a handle domain, or a role source. */ +export type RoleGrant = { role: string; source: string } + +export const ROLE_SOURCE_TIMEOUT_MS = 2000 + +export type RolesDeps = { + db: Db + adminDids: ReadonlySet + /** The role sources plugins registered, from the current plugin runtime. */ + sources: () => RoleSource[] + logger: Logger + sourceTimeoutMs?: number } -export type Roles = { rolesFor(did: string): string[]; defined: ReadonlySet } +const parseList = (json: string) => JSON.parse(json) as string[] -/** Validate the config's roles and answer which roles a DID holds. */ -export function createRoles(config: Record = {}): Roles { - for (const [role, dids] of Object.entries(config)) { - if (role === IMPLICIT_ROLE) - throw new RolesConfigError( - `The role name "${IMPLICIT_ROLE}" is implicit and cannot be defined`, - ) - if (!ROLE_PATTERN.test(role)) - throw new RolesConfigError(`Role name "${role}" must match ${ROLE_PATTERN}`) - for (const did of dids) { - if (!isValidDid(did)) - throw new RolesConfigError(`Role "${role}" lists an invalid DID: "${did}"`) +const pdsHost = (pdsUrl: string) => + URL.canParse(pdsUrl) ? new URL(pdsUrl).hostname.toLowerCase() : undefined + +const inDomain = (handle: string, domain: string) => + handle === domain || handle.endsWith(`.${domain}`) + +/** Which roles a user holds, from the database, the environment, and plugin role sources. */ +export class Roles { + private readonly deps: RolesDeps + + constructor(deps: RolesDeps) { + this.deps = deps + } + + isEnvironmentAdmin(did: string): boolean { + return this.deps.adminDids.has(did) + } + + environmentAdmins(): ReadonlySet { + return this.deps.adminDids + } + + /** Every role name that exists, including the implicit one. */ + async names(): Promise> { + const rows = await this.deps.db.selectFrom('role').select('name').execute() + return new Set([IMPLICIT_ROLE, ...rows.map((row) => row.name)]) + } + + /** Each role the user holds with where it came from. The implicit role is left out. */ + async grantsFor(identity: RoleIdentity): Promise { + const { db } = this.deps + const grants: RoleGrant[] = [] + if (this.isEnvironmentAdmin(identity.did)) + grants.push({ role: ADMIN_ROLE, source: 'environment' }) + const [members, roles] = await Promise.all([ + db.selectFrom('role_member').select('role').where('did', '=', identity.did).execute(), + db.selectFrom('role').select(['name', 'pds_hosts_json', 'handle_domains_json']).execute(), + ]) + for (const { role } of members) grants.push({ role, source: 'member' }) + const host = pdsHost(identity.pdsUrl) + const handle = identity.handle?.toLowerCase() + for (const role of roles) { + const matchedHost = host && parseList(role.pds_hosts_json).find((h) => h === host) + if (matchedHost) grants.push({ role: role.name, source: `pds:${matchedHost}` }) + const domain = handle && parseList(role.handle_domains_json).find((d) => inDomain(handle, d)) + if (domain) grants.push({ role: role.name, source: `handle:${domain}` }) + } + const known = new Set(roles.map((role) => role.name)) + for (const source of this.deps.sources()) { + for (const role of await this.ask(source, identity)) { + if (role === ADMIN_ROLE || !known.has(role)) { + this.deps.logger.warn( + { source: source.id, role, did: identity.did }, + 'ignoring a role a role source cannot grant', + ) + continue + } + grants.push({ role, source: `plugin:${source.id}` }) + } } + return grants } - return { - defined: new Set([IMPLICIT_ROLE, ...Object.keys(config)]), - rolesFor: (did) => [ - IMPLICIT_ROLE, - ...Object.entries(config) - .filter(([, dids]) => dids.includes(did)) - .map(([role]) => role), - ], + + /** The implicit role plus every role the user holds. */ + async rolesFor(identity: RoleIdentity): Promise { + const grants = await this.grantsFor(identity) + return [IMPLICIT_ROLE, ...new Set(grants.map((grant) => grant.role))] + } + + /** A role source's answer, or none when it fails or takes too long. */ + private async ask(source: RoleSource, identity: RoleIdentity): Promise { + let timer: NodeJS.Timeout | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('timed out')), + this.deps.sourceTimeoutMs ?? ROLE_SOURCE_TIMEOUT_MS, + ) + }) + try { + const roles = await Promise.race([source.rolesFor(identity), timeout]) + return Array.isArray(roles) ? roles.filter((role) => typeof role === 'string') : [] + } catch (err) { + this.deps.logger.warn({ err, source: source.id, did: identity.did }, 'role source failed') + return [] + } finally { + clearTimeout(timer) + } } } diff --git a/apps/server/src/auth/routes.ts b/apps/server/src/auth/routes.ts index 7c49f99..139c531 100644 --- a/apps/server/src/auth/routes.ts +++ b/apps/server/src/auth/routes.ts @@ -8,6 +8,8 @@ import type { Db } from '../db/index.ts' import type { AppEnv, AuthUser } from '../env.ts' import type { Logger } from '../logger.ts' import { safeErrorMessage } from '../safe-error.ts' +import type { SettingsStore } from '../settings/store.ts' +import type { Access } from './access.ts' import { type Account, getAccount, @@ -16,8 +18,9 @@ import { touchActivity, } from './accounts.ts' import type { IdentityResolver } from './identity.ts' +import { removeInvite } from './invites.ts' import type { OAuthClientLike } from './oauth-client.ts' -import type { Roles } from './roles.ts' +import { ADMIN_ROLE, type Roles } from './roles.ts' import { scopeAllowsSpaces } from './scope.ts' import { createWebSession, @@ -34,6 +37,8 @@ export type AuthDeps = { oauth: OAuthClientLike identity: IdentityResolver roles: Roles + access: Access + settings: SettingsStore scope: string /** Runs after each login to set up the user's storage. */ onLogin?: (account: Account) => Promise @@ -58,6 +63,19 @@ const NEXT_COOKIE = 'scn_next' /** Is this a path on this site, and not a link to another one? */ const isLocalPath = (path: string) => /^\/(?![/\\])/.test(path) +/** Is this the path of a shared chat, where anyone may sign in as a viewer? */ +const isSharePath = (path: string) => /^\/s\/[^/]+\/[^/]+$/.test(path) + +/** API paths a viewer may use. */ +const VIEWER_PATHS = [/^\/api\/me$/, /^\/api\/logout$/, /^\/api\/shared\//, /^\/api\/health$/] + +class AccessDenied extends Error { + constructor(message: string) { + super(message) + this.name = 'AccessDenied' + } +} + class LoginMismatch extends Error { constructor() { super('This sign-in was started in a different browser. Sign in again here.') @@ -65,13 +83,13 @@ class LoginMismatch extends Error { } } -function setSessionCookie(c: Context, token: string, config: Config) { +function setSessionCookie(c: Context, token: string, config: Config, ttlDays: number) { setCookie(c, SESSION_COOKIE, token, { httpOnly: true, sameSite: 'Lax', path: '/', secure: config.publicUrl.startsWith('https://'), - maxAge: config.sessionTtlDays * 86_400, + maxAge: ttlDays * 86_400, }) } @@ -81,12 +99,20 @@ export function sessionMiddleware(deps: AuthDeps): MiddlewareHandler { c.set('user', null) const token = getCookie(c, SESSION_COOKIE) if (token) { - const session = await resolveWebSession(deps.db, token, deps.config.sessionTtlDays) + const { ttlDays } = deps.settings.get('sessions') + const session = await resolveWebSession(deps.db, token, ttlDays) if (session) { const account = await getAccount(deps.db, session.did) if (!account) throw new Error(`A web session names ${session.did}, which has no account`) - if (session.renewed) setSessionCookie(c, token, deps.config) - c.set('user', { did: account.did, account, roles: deps.roles.rolesFor(account.did) }) + if (session.renewed) setSessionCookie(c, token, deps.config, ttlDays) + const { roles, access } = await deps.access.forAccount(account) + c.set('user', { + did: account.did, + account, + roles, + access, + admin: roles.includes(ADMIN_ROLE), + }) await touchActivity(deps.db, account) } } @@ -94,6 +120,27 @@ export function sessionMiddleware(deps: AuthDeps): MiddlewareHandler { } } +/** Why a signed-in user without full access can't use the chat app. */ +const accessMessage = (deps: AuthDeps, user: AuthUser) => + deps.access.deniedMessage(user.did, user.account, user.roles) + +/** Keep viewers to the routes for shared chats. */ +export function accessGate(deps: AuthDeps): MiddlewareHandler { + return async (c, next) => { + const user = c.get('user') + if (user && user.access !== 'full' && !VIEWER_PATHS.some((path) => path.test(c.req.path))) + return c.json({ error: 'AccessDenied', message: await accessMessage(deps, user) }, 403) + await next() + } +} + +export const requireAdmin: MiddlewareHandler = async (c, next) => { + const user = c.get('user') + if (!user) return c.json({ error: 'Unauthorized' }, 401) + if (!user.admin) return c.json({ error: 'Forbidden' }, 403) + await next() +} + /** Refuse state-changing API requests from other origins. */ export function originCheck(config: Config): MiddlewareHandler { const allowed = new URL(config.publicUrl).origin @@ -120,7 +167,15 @@ export function signedInUser(c: { get(key: 'user'): AuthUser | null }): AuthUser export function oauthRoutes(deps: AuthDeps) { const secure = deps.config.publicUrl.startsWith('https://') return new Hono() - .get('/oauth-client-metadata.json', (c) => c.json(deps.oauth.clientMetadata)) + .get('/oauth-client-metadata.json', (c) => { + const metadata = deps.oauth.clientMetadata + // The app name can change while the server runs. + return c.json( + 'client_name' in metadata + ? { ...metadata, client_name: deps.settings.get('general').appName } + : metadata, + ) + }) .get('/oauth/jwks.json', (c) => c.json(deps.oauth.jwks)) .get('/oauth/login', async (c) => { const identifier = c.req.query('identifier')?.trim() @@ -156,26 +211,40 @@ export function oauthRoutes(deps: AuthDeps) { if (!expected || state !== expected) throw new LoginMismatch() const { scope } = await session.getTokenInfo(false) const identity = await deps.identity.resolve(session.did) + const existing = await getAccount(deps.db, session.did) + const roles = await deps.roles.rolesFor(identity) + const full = await deps.access.allowsSignIn(session.did, existing, roles) + if (!full && !(next && isSharePath(next))) + throw new AccessDenied(await deps.access.deniedMessage(session.did, existing, roles)) const account = await recordLogin(deps.db, { did: session.did, handle: identity.handle, pdsUrl: identity.pdsUrl, spacesAllowed: scopeAllowsSpaces(scope, session.did), + viewer: !full, }) if (account.storageMode === 'local') deps.logger.info({ did: account.did, scope }, 'signed in with local storage') - if (deps.onLogin) { + if (!full) deps.logger.info({ did: account.did }, 'signed in as a viewer') + else await removeInvite(deps.db, account.did) + if (full && deps.onLogin) { await deps .onLogin(account) .catch((err) => deps.logger.error({ err, did: account.did }, 'login setup failed')) } - const token = await createWebSession(deps.db, account.did, deps.config.sessionTtlDays) - setSessionCookie(c, token, deps.config) + const { ttlDays } = deps.settings.get('sessions') + const token = await createWebSession(deps.db, account.did, ttlDays) + setSessionCookie(c, token, deps.config, ttlDays) return c.redirect(next && isLocalPath(next) ? next : '/') } catch (err) { deps.logger.warn({ err, did }, 'oauth callback failed') // Revoke the tokens the OAuth client already stored for a sign-in that can't be used. - if (did && (err instanceof SpacesLostError || err instanceof LoginMismatch)) + if ( + did && + (err instanceof SpacesLostError || + err instanceof LoginMismatch || + err instanceof AccessDenied) + ) await deps.oauth .revoke(did) .catch((revokeErr: unknown) => @@ -189,14 +258,18 @@ export function oauthRoutes(deps: AuthDeps) { /** The signed-in user's details, and logout. */ export function authApiRoutes(deps: AuthDeps) { return new Hono() - .get('/me', requireUser, (c) => { - const { did, account, roles } = signedInUser(c) + .get('/me', requireUser, async (c) => { + const user = signedInUser(c) + const { did, account, roles, access, admin } = user return c.json({ did, handle: account.handle, storageMode: account.storageMode, backgroundSync: account.backgroundSync, roles, + admin, + access, + accessMessage: access === 'full' ? null : await accessMessage(deps, user), }) }) .post('/logout', async (c) => { diff --git a/apps/server/src/blobs/routes.ts b/apps/server/src/blobs/routes.ts index cce130b..4925536 100644 --- a/apps/server/src/blobs/routes.ts +++ b/apps/server/src/blobs/routes.ts @@ -43,7 +43,7 @@ async function readLimited( export type BlobRoutesDeps = { blobsFor: (account: Parameters[0]) => BlobStore services: ChatServices - ingesters: IngesterRegistry + ingesters: () => IngesterRegistry logger: Logger } @@ -52,7 +52,7 @@ export function blobRoutes(deps: BlobRoutesDeps) { return new Hono() .use(requireUser) .get('/attachments/types', (c) => - c.json({ images: [...IMAGE_TYPES], files: deps.ingesters.accepted() }), + c.json({ images: [...IMAGE_TYPES], files: deps.ingesters().accepted() }), ) .post('/attachments', async (c) => { const { account } = signedInUser(c) @@ -67,7 +67,7 @@ export function blobRoutes(deps: BlobRoutesDeps) { if (name.length > MAX_NAME_LENGTH) return c.json({ error: 'InvalidRequest', message: 'Filename is too long' }, 400) const image = IMAGE_TYPES.has(mimeType) - const ingester = image ? undefined : deps.ingesters.match(mimeType) + const ingester = image ? undefined : deps.ingesters().match(mimeType) if (!image && !ingester) return c.json( { error: 'UnsupportedMediaType', message: `Files of type ${mimeType} are not supported` }, diff --git a/apps/server/src/body.ts b/apps/server/src/body.ts index 0296225..e80dcd2 100644 --- a/apps/server/src/body.ts +++ b/apps/server/src/body.ts @@ -1,11 +1,38 @@ import type { Context } from 'hono' import type { z } from 'zod' +export type Issue = { path: (string | number)[]; message: string } + export class InvalidBody extends Error { - constructor(details: string) { - super(`Invalid request body: ${details}`) + /** Each problem with the field it belongs to, for forms. */ + readonly issues: Issue[] + + constructor(message: string, issues: Issue[] = []) { + super(message) this.name = 'InvalidBody' + this.issues = issues + } +} + +/** Plain issues from a zod error, for responses. */ +export const issuesOf = (error: z.ZodError): Issue[] => + error.issues.map((issue) => ({ + path: issue.path.filter((key) => typeof key !== 'symbol') as (string | number)[], + message: issue.message, + })) + +/** Describe issues in one line, each prefixed with its field. */ +export const describeIssues = (issues: Issue[], fallback = 'body') => + issues.map((issue) => `${issue.path.join('.') || fallback}: ${issue.message}`).join('; ') + +/** Validate a value against the schema, throwing InvalidBody with every issue. */ +export function validate(schema: S, value: unknown): z.infer { + const result = schema.safeParse(value) + if (!result.success) { + const issues = issuesOf(result.error) + throw new InvalidBody(describeIssues(issues), issues) } + return result.data } /** Parse a JSON request body and validate it against the schema. */ @@ -20,16 +47,8 @@ export async function jsonBody( try { raw = JSON.parse(text) } catch { - throw new InvalidBody('not JSON') + throw new InvalidBody('The request body is not JSON') } } - const result = schema.safeParse(raw) - if (!result.success) { - throw new InvalidBody( - result.error.issues - .map((issue) => `${issue.path.join('.') || 'body'}: ${issue.message}`) - .join('; '), - ) - } - return result.data + return validate(schema, raw) } diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 93bb4fb..48b90ff 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -1,11 +1,8 @@ -import { existsSync, readFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { parse, YAMLParseError } from 'yaml' +import { isValidDid } from '@atproto/syntax' import { z } from 'zod' export class ConfigError extends Error { - /** The settings or environment variables at fault. */ + /** The environment variables at fault. */ readonly names: string[] constructor(names: string[], details: string) { @@ -17,104 +14,30 @@ export class ConfigError extends Error { export const DEFAULT_DATABASE_URL = 'sqlite:./data/scn-chat.sqlite' -/** The start of every turn's instructions. Placeholders: {{appName}}, {{date}}, {{time}}, {{timezone}}. */ -export const DEFAULT_SYSTEM_PROMPT = - 'You are an AI assistant in {{appName}}. Today is {{date}} ({{timezone}}). ' + - 'Replies are rendered as Markdown. Raw HTML is not rendered, and images load only when the user clicks them.' - const key32 = z .string() .refine((value) => Buffer.from(value, 'base64').length === 32, 'must be 32 bytes, base64 encoded') -const capabilities = z.object({ vision: z.boolean(), reasoning: z.boolean(), tools: z.boolean() }) +const list = z + .string() + .default('') + .transform((value) => + value + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + ) -const fileSchema = z - .object({ - app: z - .object({ - name: z.string().min(1).default('SCN Chat'), - publicUrl: z.url().default('http://127.0.0.1:3000'), - port: z.coerce.number().int().min(1).max(65535).default(3000), - dataDir: z.string().min(1).default('./data'), - logLevel: z - .enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']) - .default('info'), - /** Let the server reach PDSes and DID documents on private networks, for a local PDS. */ - allowPrivateNetworks: z.boolean().default(false), - }) - .prefault({}), - auth: z - .object({ - scopeMode: z.enum(['permission-set', 'raw']).default('permission-set'), - sessionTtlDays: z.coerce.number().int().min(1).default(30), - plcUrl: z.url().default('https://plc.directory'), - }) - .prefault({}), - turns: z - .object({ - ratePerMinute: z.coerce.number().int().min(1).default(10), - maxSteps: z.coerce.number().int().min(1).default(8), - timeoutSeconds: z.coerce.number().int().min(1).default(600), - backfillMinutes: z.coerce.number().int().min(0).default(60), - systemPrompt: z.string().default(DEFAULT_SYSTEM_PROMPT), - }) - .prefault({}), - sync: z - .object({ - safetyNet: z - .object({ - enabled: z.boolean().optional(), - intervalMinutes: z.number().positive().optional(), - activeWithinHours: z.number().positive().optional(), - }) - .optional(), - discovery: z - .object({ - intervalMinutes: z.number().positive().optional(), - activeWithinDays: z.number().positive().optional(), - }) - .optional(), - allowUserOptOut: z.boolean().optional(), - }) - .prefault({}), - roles: z.record(z.string(), z.array(z.string())).default({}), - plugins: z - .array( - z.object({ - package: z.string().min(1), - options: z.record(z.string(), z.unknown()).optional(), - }), - ) - .default([]), - models: z - .array( - z.object({ - provider: z.string().min(1), - id: z.string().min(1), - name: z.string().min(1), - capabilities, - roles: z.array(z.string()), - default: z.boolean().optional(), - }), - ) - .default([]), - }) - .strict() +const flag = z + .enum(['true', 'false', '1', '0']) + .default('false') + .transform((value) => value === 'true' || value === '1') const envSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), DATABASE_URL: z.string().min(1).default(DEFAULT_DATABASE_URL), SECRET_KEY: key32, - SECRET_KEYS_OLD: z - .string() - .default('') - .transform((value) => - value - .split(',') - .map((key) => key.trim()) - .filter(Boolean), - ) - .pipe(z.array(key32)), + SECRET_KEYS_OLD: list.pipe(z.array(key32)), OAUTH_PRIVATE_KEYS: z .string() .default('[]') @@ -127,34 +50,31 @@ const envSchema = z.object({ } }) .pipe(z.array(z.looseObject({ kid: z.string().min(1), kty: z.string(), d: z.string() }))), + ADMIN_DIDS: list.pipe( + z + .array(z.string()) + .min(1, 'must list at least one admin DID') + .superRefine((dids, ctx) => { + for (const did of dids) { + if (!isValidDid(did)) + ctx.addIssue({ code: 'custom', message: `holds an invalid DID: "${did}"` }) + } + }), + ), + PUBLIC_URL: z.url().default('http://127.0.0.1:3000'), + PORT: z.coerce.number().int().min(1).max(65535).default(3000), + DATA_DIR: z.string().min(1).default('./data'), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'), + /** Let the server reach PDSes and DID documents on private networks, for a local PDS. */ + ALLOW_PRIVATE_NETWORKS: flag, + OAUTH_SCOPE_MODE: z.enum(['permission-set', 'raw']).default('permission-set'), + PLC_URL: z.url().default('https://plc.directory'), }) -/** Environment variables that override public settings. */ -export const ENV_OVERRIDES: Record = { - APP_NAME: ['app', 'name'], - PUBLIC_URL: ['app', 'publicUrl'], - PORT: ['app', 'port'], - DATA_DIR: ['app', 'dataDir'], - LOG_LEVEL: ['app', 'logLevel'], - OAUTH_SCOPE_MODE: ['auth', 'scopeMode'], - SESSION_TTL_DAYS: ['auth', 'sessionTtlDays'], - PLC_URL: ['auth', 'plcUrl'], - TURN_RATE_PER_MINUTE: ['turns', 'ratePerMinute'], - TURN_MAX_STEPS: ['turns', 'maxSteps'], - TURN_TIMEOUT_SECONDS: ['turns', 'timeoutSeconds'], - TURN_BACKFILL_MINUTES: ['turns', 'backfillMinutes'], -} - -export type FileConfig = z.infer -export type ModelConfig = FileConfig['models'][number] -export type SyncConfig = FileConfig['sync'] -export type PluginEntry = FileConfig['plugins'][number] - export type Config = Readonly<{ nodeEnv: 'development' | 'test' | 'production' port: number publicUrl: string - appName: string dataDir: string databaseUrl: string logLevel: string @@ -163,122 +83,31 @@ export type Config = Readonly<{ oldSecretKeys: Buffer[] oauthPrivateKeys: { kid: string; [key: string]: unknown }[] oauthScopeMode: 'permission-set' | 'raw' - sessionTtlDays: number plcUrl: string - turns: { - ratePerMinute: number - maxSteps: number - timeoutMs: number - backfillWindowMs: number - systemPrompt: string - } - configDir: string - plugins: PluginEntry[] - models: ModelConfig[] - roles: Record - sync: SyncConfig + adminDids: readonly string[] }> -const EXACT_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/ -const REFERENCE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g - -/** Replace ${NAME} references with environment values. An exact reference to a missing variable becomes unset. */ -export function interpolate(value: unknown, env: Record): unknown { - if (typeof value === 'string') { - const exact = value.match(EXACT_REFERENCE) - if (exact?.[1]) return env[exact[1]] || undefined - return value.replace(REFERENCE, (_match, name: string) => { - const found = env[name] - if (!found) - throw new ConfigError([name], `referenced in config.yml but not set in the environment`) - return found - }) - } - if (Array.isArray(value)) - return value.map((item) => interpolate(item, env)).filter((item) => item !== undefined) - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value) - .map(([key, item]) => [key, interpolate(item, env)] as const) - .filter(([, item]) => item !== undefined), - ) - } - return value -} - -/** Read and parse config.yml, failing with a pointer to the example when it is missing. */ -export function readConfigFile(path: string): unknown { - if (!existsSync(path)) { - throw new ConfigError( - ['config.yml'], - `no config file at ${path}. Copy config.example.yml to config.yml and edit it, or set SCN_CHAT_CONFIG.`, - ) - } - try { - return parse(readFileSync(path, 'utf8')) ?? {} - } catch (err) { - if (err instanceof YAMLParseError) - throw new ConfigError(['config.yml'], `${resolve(path)} is not valid YAML: ${err.message}`) - throw err - } -} - -export const defaultConfigPath = resolve( - dirname(fileURLToPath(import.meta.url)), - '../../../config.yml', -) - -function issuesToError(issues: z.core.$ZodIssue[], prefix = ''): ConfigError { - const names = [ - ...new Set(issues.map((issue) => `${prefix}${issue.path.join('.')}` || 'config.yml')), - ] - const details = issues - .map((issue) => `${prefix}${issue.path.join('.')}: ${issue.message}`) - .join('; ') - return new ConfigError(names, details) -} - -/** Build the config from config.yml's contents and the environment, once at startup. */ -export function loadConfig(options: { - env?: Record - file?: unknown - configPath?: string -}): Config { - const env = options.env ?? process.env - const raw = interpolate(options.file ?? {}, env) as Record> - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) - throw new ConfigError(['config.yml'], 'must be a mapping') - for (const [variable, [section, key]] of Object.entries(ENV_OVERRIDES)) { - const value = env[variable] - if (value !== undefined && value !== '') - raw[section] = { ...(raw[section] ?? {}), [key]: value } - } - const fileResult = fileSchema.safeParse(raw) - const envResult = envSchema.safeParse( +/** Build the config from the environment, once at startup. */ +export function loadConfig(env: Record = process.env): Config { + const result = envSchema.safeParse( Object.fromEntries(Object.entries(env).filter(([, value]) => value !== '')), ) - const issues = [ - ...(fileResult.success ? [] : [issuesToError(fileResult.error.issues)]), - ...(envResult.success ? [] : [issuesToError(envResult.error.issues)]), - ] - if (issues.length) { - throw new ConfigError( - issues.flatMap((error) => error.names), - issues - .map((error) => error.message.replace(/^Invalid configuration for [^:]+: /, '')) - .join('; '), - ) + if (!result.success) { + const names = [...new Set(result.error.issues.map((issue) => String(issue.path[0])))] + const details = result.error.issues + .map((issue) => `${String(issue.path[0])}: ${issue.message}`) + .join('; ') + throw new ConfigError(names, details) } - const file = fileResult.data as FileConfig - const secrets = envResult.data as z.infer - if (secrets.NODE_ENV === 'production') { + const values = result.data + if (values.NODE_ENV === 'production') { const problems: [string, string][] = [] - if (!file.app.publicUrl.startsWith('https://')) - problems.push(['app.publicUrl', 'must be HTTPS in production']) - if (secrets.OAUTH_PRIVATE_KEYS.length === 0) + if (!values.PUBLIC_URL.startsWith('https://')) + problems.push(['PUBLIC_URL', 'must be HTTPS in production']) + if (values.OAUTH_PRIVATE_KEYS.length === 0) problems.push(['OAUTH_PRIVATE_KEYS', 'is required in production']) - if (file.auth.scopeMode === 'raw') - problems.push(['auth.scopeMode', 'raw is for local development only']) + if (values.OAUTH_SCOPE_MODE === 'raw') + problems.push(['OAUTH_SCOPE_MODE', 'raw is for local development only']) if (problems.length) { throw new ConfigError( problems.map(([name]) => name), @@ -287,31 +116,18 @@ export function loadConfig(options: { } } return Object.freeze({ - nodeEnv: secrets.NODE_ENV, - port: file.app.port, - publicUrl: file.app.publicUrl.replace(/\/$/, ''), - appName: file.app.name, - dataDir: file.app.dataDir, - databaseUrl: secrets.DATABASE_URL, - logLevel: file.app.logLevel, - allowPrivateNetworks: file.app.allowPrivateNetworks, - secretKey: Buffer.from(secrets.SECRET_KEY, 'base64'), - oldSecretKeys: secrets.SECRET_KEYS_OLD.map((key) => Buffer.from(key, 'base64')), - oauthPrivateKeys: secrets.OAUTH_PRIVATE_KEYS, - oauthScopeMode: file.auth.scopeMode, - sessionTtlDays: file.auth.sessionTtlDays, - plcUrl: file.auth.plcUrl, - turns: { - ratePerMinute: file.turns.ratePerMinute, - maxSteps: file.turns.maxSteps, - timeoutMs: file.turns.timeoutSeconds * 1000, - backfillWindowMs: file.turns.backfillMinutes * 60_000, - systemPrompt: file.turns.systemPrompt, - }, - configDir: dirname(options.configPath ?? defaultConfigPath), - plugins: file.plugins, - models: file.models, - roles: file.roles, - sync: file.sync, + nodeEnv: values.NODE_ENV, + port: values.PORT, + publicUrl: values.PUBLIC_URL.replace(/\/$/, ''), + dataDir: values.DATA_DIR, + databaseUrl: values.DATABASE_URL, + logLevel: values.LOG_LEVEL, + allowPrivateNetworks: values.ALLOW_PRIVATE_NETWORKS, + secretKey: Buffer.from(values.SECRET_KEY, 'base64'), + oldSecretKeys: values.SECRET_KEYS_OLD.map((key) => Buffer.from(key, 'base64')), + oauthPrivateKeys: values.OAUTH_PRIVATE_KEYS, + oauthScopeMode: values.OAUTH_SCOPE_MODE, + plcUrl: values.PLC_URL, + adminDids: Object.freeze([...values.ADMIN_DIDS]), }) } diff --git a/apps/server/src/db/migrations/0007_admin.ts b/apps/server/src/db/migrations/0007_admin.ts new file mode 100644 index 0000000..ffb9a10 --- /dev/null +++ b/apps/server/src/db/migrations/0007_admin.ts @@ -0,0 +1,45 @@ +import type { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('app_setting') + .addColumn('key', 'text', (col) => col.primaryKey()) + .addColumn('value_json', 'text', (col) => col.notNull()) + .addColumn('updated_at', 'text', (col) => col.notNull()) + .addColumn('updated_by', 'text', (col) => col.notNull()) + .execute() + await db.schema + .createTable('role') + .addColumn('name', 'text', (col) => col.primaryKey()) + .addColumn('description', 'text', (col) => col.notNull()) + .addColumn('pds_hosts_json', 'text', (col) => col.notNull()) + .addColumn('handle_domains_json', 'text', (col) => col.notNull()) + .addColumn('created_at', 'text', (col) => col.notNull()) + .addColumn('updated_at', 'text', (col) => col.notNull()) + .execute() + await db.schema + .createTable('role_member') + .addColumn('role', 'text', (col) => col.notNull()) + .addColumn('did', 'text', (col) => col.notNull()) + .addColumn('added_at', 'text', (col) => col.notNull()) + .addColumn('added_by', 'text', (col) => col.notNull()) + .addPrimaryKeyConstraint('role_member_pk', ['role', 'did']) + .execute() + await db.schema.createIndex('role_member_did').on('role_member').column('did').execute() + const now = new Date().toISOString() + await (db as Kysely<{ role: Record }>) + .insertInto('role') + .values({ + name: 'admin', + description: 'Can use the admin area.', + pds_hosts_json: '[]', + handle_domains_json: '[]', + created_at: now, + updated_at: now, + }) + .execute() + await db.schema + .alterTable('account') + .addColumn('viewer_only', 'integer', (col) => col.notNull().defaultTo(0)) + .execute() +} diff --git a/apps/server/src/db/migrations/0008_plugin_instances.ts b/apps/server/src/db/migrations/0008_plugin_instances.ts new file mode 100644 index 0000000..4b4f4aa --- /dev/null +++ b/apps/server/src/db/migrations/0008_plugin_instances.ts @@ -0,0 +1,30 @@ +import type { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('plugin_instance') + .addColumn('id', 'text', (col) => col.primaryKey()) + .addColumn('package', 'text', (col) => col.notNull()) + .addColumn('position', 'integer', (col) => col.notNull()) + .addColumn('enabled', 'integer', (col) => col.notNull()) + .addColumn('options_json', 'text', (col) => col.notNull()) + .addColumn('secrets_encrypted', 'text') + .addColumn('created_at', 'text', (col) => col.notNull()) + .addColumn('updated_at', 'text', (col) => col.notNull()) + .addColumn('updated_by', 'text', (col) => col.notNull()) + .execute() + await db.schema + .createTable('admin_model') + .addColumn('provider', 'text', (col) => col.notNull()) + .addColumn('model_id', 'text', (col) => col.notNull()) + .addColumn('name', 'text', (col) => col.notNull()) + .addColumn('capabilities_json', 'text', (col) => col.notNull()) + .addColumn('roles_json', 'text', (col) => col.notNull()) + .addColumn('is_default', 'integer', (col) => col.notNull()) + .addColumn('position', 'integer', (col) => col.notNull()) + .addColumn('created_at', 'text', (col) => col.notNull()) + .addColumn('updated_at', 'text', (col) => col.notNull()) + .addColumn('updated_by', 'text', (col) => col.notNull()) + .addPrimaryKeyConstraint('admin_model_pk', ['provider', 'model_id']) + .execute() +} diff --git a/apps/server/src/db/migrations/0009_registration.ts b/apps/server/src/db/migrations/0009_registration.ts new file mode 100644 index 0000000..3b923b1 --- /dev/null +++ b/apps/server/src/db/migrations/0009_registration.ts @@ -0,0 +1,13 @@ +import type { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema.alterTable('account').addColumn('suspended_at', 'text').execute() + await db.schema.alterTable('account').addColumn('suspended_by', 'text').execute() + await db.schema.alterTable('account').addColumn('suspended_reason', 'text').execute() + await db.schema + .createTable('account_invite') + .addColumn('did', 'text', (col) => col.primaryKey()) + .addColumn('added_by', 'text', (col) => col.notNull()) + .addColumn('added_at', 'text', (col) => col.notNull()) + .execute() +} diff --git a/apps/server/src/db/migrations/index.ts b/apps/server/src/db/migrations/index.ts index a3a2789..37fc589 100644 --- a/apps/server/src/db/migrations/index.ts +++ b/apps/server/src/db/migrations/index.ts @@ -5,6 +5,9 @@ import * as m0003 from './0003_storage.ts' import * as m0004 from './0004_providers.ts' import * as m0005 from './0005_turns.ts' import * as m0006 from './0006_user_tool_settings.ts' +import * as m0007 from './0007_admin.ts' +import * as m0008 from './0008_plugin_instances.ts' +import * as m0009 from './0009_registration.ts' /** App migrations in order. Keys sort in the order they run. */ export const migrations: Record = { @@ -14,4 +17,7 @@ export const migrations: Record = { '0004_providers': m0004, '0005_turns': m0005, '0006_user_tool_settings': m0006, + '0007_admin': m0007, + '0008_plugin_instances': m0008, + '0009_registration': m0009, } diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index c1b1db1..aa187a2 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -24,7 +24,12 @@ export type Database = { created_at: string last_login_at: string last_active_at: string + viewer_only: Generated + suspended_at: string | null + suspended_by: string | null + suspended_reason: string | null } + account_invite: { did: string; added_by: string; added_at: string } web_session: { token_hash: string; did: string; created_at: string; expires_at: string } oauth_state: { key: string; value: string; updated_at: string } oauth_session: { key: string; value: string; updated_at: string } @@ -80,4 +85,37 @@ export type Database = { owner_did: string claimed_at: string } + app_setting: { key: string; value_json: string; updated_at: string; updated_by: string } + role: { + name: string + description: string + pds_hosts_json: string + handle_domains_json: string + created_at: string + updated_at: string + } + role_member: { role: string; did: string; added_at: string; added_by: string } + plugin_instance: { + id: string + package: string + position: number + enabled: number + options_json: string + secrets_encrypted: string | null + created_at: string + updated_at: string + updated_by: string + } + admin_model: { + provider: string + model_id: string + name: string + capabilities_json: string + roles_json: string + is_default: number + position: number + created_at: string + updated_at: string + updated_by: string + } } diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index aa96aba..cd4e498 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -1,6 +1,13 @@ +import type { AccessLevel } from './auth/access.ts' import type { Account } from './auth/accounts.ts' -export type AuthUser = { did: string; account: Account; roles: string[] } +export type AuthUser = { + did: string + account: Account + roles: string[] + access: AccessLevel + admin: boolean +} /** Hono context variables shared by every route. */ export type AppEnv = { Variables: { user: AuthUser | null } } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 8524fe0..d80b179 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,33 +1,38 @@ import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { serve } from '@hono/node-server' -import { defaultConfigPath, loadConfig, readConfigFile } from './config.ts' +import { loadConfig } from './config.ts' import { createDb } from './db/index.ts' import { createLogger } from './logger.ts' -import { importPlugins } from './plugins/import.ts' +import { findInstalledPlugins } from './plugins/installed.ts' import { createServer } from './server.ts' if (existsSync('.env')) process.loadEnvFile('.env') else if (existsSync('../../.env')) process.loadEnvFile('../../.env') -const configPath = process.env.SCN_CHAT_CONFIG || defaultConfigPath -const config = loadConfig({ env: process.env, file: readConfigFile(configPath), configPath }) +const config = loadConfig(process.env) const logger = createLogger(config.logLevel) process.on('unhandledRejection', (err) => logger.error({ err }, 'unhandled promise rejection')) const db = createDb(config.databaseUrl) -const plugins = await importPlugins(config.plugins, config.configDir) +const installed = await findInstalledPlugins( + fileURLToPath(new URL('../../..', import.meta.url)), + logger, +) const webDist = fileURLToPath(new URL('../../web/dist', import.meta.url)) const server = await createServer({ config, - appConfig: { plugins, models: config.models, roles: config.roles, sync: config.sync }, db, logger, + installed, webDist: config.nodeEnv === 'production' ? webDist : undefined, }) const http = serve({ fetch: server.app.fetch, port: config.port }, ({ port }) => { - logger.info({ port, publicUrl: config.publicUrl }, `${config.appName} server listening`) + logger.info( + { port, publicUrl: config.publicUrl }, + `${server.settings.get('general').appName} server listening`, + ) }) const shutdown = async () => { diff --git a/apps/server/src/plugins/admin.ts b/apps/server/src/plugins/admin.ts new file mode 100644 index 0000000..1f93953 --- /dev/null +++ b/apps/server/src/plugins/admin.ts @@ -0,0 +1,275 @@ +import { randomUUID } from 'node:crypto' +import type { ModelInfo } from '@scn-chat/plugin-api' +import { InvalidBody } from '../body.ts' +import type { Db } from '../db/index.ts' +import type { Logger } from '../logger.ts' +import { listAdminModels, servesAdminModels } from '../providers/admin-models.ts' +import type { SecretBox } from '../secrets.ts' +import { type LoadDeps, loadPlugins } from './host.ts' +import { + type InstalledPlugin, + type InstalledPlugins, + optionsJsonSchema, + secretKeys, +} from './installed.ts' +import { type PluginInstance, readInstances, writeInstances } from './instances.ts' +import { + instantiate, + PluginInstanceError, + type PluginRuntime, + type RuntimeBuilder, + type RuntimeHolder, +} from './runtime.ts' + +export type PluginAdminDeps = { + db: Db + box: SecretBox + installed: InstalledPlugins + holder: RuntimeHolder + build: RuntimeBuilder + /** For the scratch plugins that list a provider's models. */ + load: LoadDeps + logger: Logger +} + +export type InstanceChange = { + options?: Record + clearSecrets?: string[] + enabled?: boolean +} + +export class InstanceNotFound extends Error { + constructor(id: string) { + super(`No plugin instance ${id}`) + this.name = 'InstanceNotFound' + } +} + +const isBlank = (value: unknown) => value === undefined || value === null || value === '' + +/** Split form input into stored options and secrets. A blank secret keeps the stored one. */ +function splitOptions( + installed: InstalledPlugin | undefined, + input: Record, + stored: Record = {}, + clear: string[] = [], +) { + const keys = secretKeys(installed?.optionsSchema) + const options = Object.fromEntries( + Object.entries(input).filter(([key, value]) => !keys.includes(key) && !isBlank(value)), + ) + const secrets: Record = { ...stored } + for (const key of keys) if (!isBlank(input[key])) secrets[key] = input[key] + for (const key of clear) delete secrets[key] + return { options, secrets } +} + +/** Adding, changing, and removing plugin instances, each applied without a restart. */ +export class PluginAdmin { + private readonly deps: PluginAdminDeps + + constructor(deps: PluginAdminDeps) { + this.deps = deps + } + + installed() { + return [...this.deps.installed.values()].map((plugin) => ({ + package: plugin.package, + description: plugin.description, + version: plugin.version, + schema: optionsJsonSchema(plugin), + secretFields: secretKeys(plugin.optionsSchema), + multiple: plugin.multiple, + })) + } + + /** Every instance with its form, options without secrets, and load status. */ + async list() { + const statuses = this.deps.holder.current().statuses + return (await readInstances(this.deps.db, this.deps.box)).map((instance) => { + const installed = this.deps.installed.get(instance.package) + const status = statuses.get(instance.id) + return { + id: instance.id, + package: instance.package, + enabled: instance.enabled, + options: instance.options, + secretFields: secretKeys(installed?.optionsSchema), + secretsSet: Object.keys(instance.secrets), + schema: installed ? optionsJsonSchema(installed) : null, + pluginId: status?.state === 'loaded' ? status.pluginId : null, + name: status?.state === 'loaded' ? status.name : null, + providers: + status?.state === 'loaded' + ? this.deps.holder + .current() + .host.providers.list() + .filter( + (provider) => + this.deps.holder.current().host.providers.owner(provider.id) === + status.pluginId, + ) + .map((provider) => ({ + id: provider.id, + name: provider.name, + hasAdminKey: provider.hasAdminKey, + listsModels: Boolean(provider.listModels), + })) + : [], + status: status?.state ?? 'failed', + error: status?.state === 'failed' ? status.error : null, + issues: status?.state === 'failed' ? status.issues : [], + } + }) + } + + async add(pkg: string, input: Record, adminDid: string): Promise { + const installed = this.deps.installed.get(pkg) + if (!installed) throw new InvalidBody(`The plugin package "${pkg}" isn't installed`) + const id = randomUUID() + const instances = await readInstances(this.deps.db, this.deps.box) + if (!installed.multiple && instances.some((instance) => instance.package === pkg)) + throw new InvalidBody(`${pkg} is already added, and can only be added once`) + instances.push({ id, package: pkg, enabled: true, ...splitOptions(installed, input) }) + await this.apply(instances, id, adminDid) + return id + } + + async update(id: string, change: InstanceChange, adminDid: string): Promise { + const instances = await readInstances(this.deps.db, this.deps.box) + const index = instances.findIndex((instance) => instance.id === id) + const instance = instances[index] + if (!instance) throw new InstanceNotFound(id) + const installed = this.deps.installed.get(instance.package) + const split = splitOptions( + installed, + change.options ?? { ...instance.options }, + instance.secrets, + change.clearSecrets, + ) + instances[index] = { ...instance, ...split, enabled: change.enabled ?? instance.enabled } + await this.apply(instances, id, adminDid) + } + + async remove(id: string, adminDid: string): Promise { + const instances = await readInstances(this.deps.db, this.deps.box) + if (!instances.some((instance) => instance.id === id)) throw new InstanceNotFound(id) + await this.apply( + instances.filter((instance) => instance.id !== id), + undefined, + adminDid, + ) + } + + async reorder(ids: string[], adminDid: string): Promise { + const instances = await readInstances(this.deps.db, this.deps.box) + const byId = new Map(instances.map((instance) => [instance.id, instance])) + if ( + ids.length !== instances.length || + new Set(ids).size !== ids.length || + ids.some((id) => !byId.has(id)) + ) + throw new InvalidBody('The new order must list every plugin instance once') + await this.apply( + ids.map((id) => byId.get(id) as PluginInstance), + undefined, + adminDid, + ) + } + + /** + * The models of the providers a plugin registers, using unsaved options from its form. + * Blank secrets come from the stored instance. + */ + async listModels( + pkg: string, + instanceId: string | undefined, + input: Record, + ): Promise<(ModelInfo & { provider: string })[]> { + const installed = this.deps.installed.get(pkg) + if (!installed) throw new InvalidBody(`The plugin package "${pkg}" isn't installed`) + const stored = instanceId + ? (await readInstances(this.deps.db, this.deps.box)).find((i) => i.id === instanceId) + : undefined + const instance: PluginInstance = { + id: instanceId ?? 'scratch', + package: pkg, + enabled: true, + ...splitOptions(installed, input, stored?.secrets), + } + let plugin: ReturnType + try { + plugin = instantiate(this.deps.installed, instance) + } catch (err) { + if (err instanceof PluginInstanceError) throw new InvalidBody(err.message, err.issues) + throw err + } + const host = await loadPlugins([plugin], this.deps.load) + try { + const models: (ModelInfo & { provider: string })[] = [] + for (const provider of host.providers.list()) { + if (!provider.listModels) continue + for (const model of await provider.listModels({})) + models.push({ ...model, provider: provider.id }) + } + return models + } finally { + await host.close() + } + } + + /** Build a runtime from the proposed instances, and switch to it unless it breaks something. */ + private apply(proposed: PluginInstance[], changedId: string | undefined, adminDid: string) { + const { holder } = this.deps + return holder.exclusive(async () => { + const current = holder.current() + const candidate = await this.deps.build(proposed) + try { + this.assertNothingBreaks(current, candidate, changedId) + await this.assertModelsKeepProviders(current, candidate) + } catch (err) { + await candidate.host.close() + throw err + } + await writeInstances(this.deps.db, this.deps.box, proposed, adminDid) + holder.swap(candidate) + this.deps.logger.info({ admin: adminDid, instance: changedId }, 'plugins changed') + }) + } + + /** Refuse a change that fails the changed plugin, breaks a working one, or renames one. */ + private assertNothingBreaks( + current: PluginRuntime, + candidate: PluginRuntime, + changedId: string | undefined, + ) { + for (const [id, status] of candidate.statuses) { + const before = current.statuses.get(id) + if (status.state === 'failed' && (id === changedId || before?.state === 'loaded')) + throw new InvalidBody(status.error, id === changedId ? status.issues : []) + if ( + status.state === 'loaded' && + before?.state === 'loaded' && + before.pluginId !== status.pluginId + ) + throw new InvalidBody( + `This change would rename the plugin from ${before.pluginId} to ${status.pluginId}. Add a new instance instead.`, + ) + } + } + + /** Refuse a change that leaves an admin model without the provider it had. */ + private async assertModelsKeepProviders(current: PluginRuntime, candidate: PluginRuntime) { + const stranded = (await listAdminModels(this.deps.db)).filter( + (model) => + servesAdminModels(current.host.providers, model.provider) && + !servesAdminModels(candidate.host.providers, model.provider), + ) + if (stranded.length) + throw new InvalidBody( + `This change would leave these models without their provider: ${stranded + .map((model) => `${model.provider}/${model.id}`) + .join(', ')}. Remove them first.`, + ) + } +} diff --git a/apps/server/src/plugins/host.ts b/apps/server/src/plugins/host.ts index 95dc8d4..0349361 100644 --- a/apps/server/src/plugins/host.ts +++ b/apps/server/src/plugins/host.ts @@ -1,5 +1,8 @@ import { type GenerateTextRequest, + type HookHandler, + type HookName, + type HookOrder, type InfoPatch, type Ingester, type Logger, @@ -7,6 +10,7 @@ import { PLUGIN_API_VERSION, type Plugin, type PluginContext, + type RoleSource, type Tool, type ToolSource, } from '@scn-chat/plugin-api' @@ -30,6 +34,9 @@ export type PluginServices = { options?: { unlessUserTitled?: boolean }, ): Promise userSettings(plugin: Plugin, user: string): Promise + /** Suspend or restore an account on behalf of `by`, such as plugin:. */ + suspendAccount(did: string, by: string, reason?: string): Promise + restoreAccount(did: string, by: string): Promise } export type PluginHost = { @@ -37,74 +44,144 @@ export type PluginHost = { providers: Registry tools: Registry> toolSources: Registry + roleSources: Registry ingesters: IngesterRegistry hooks: HookRunner close(): Promise } +export type LoadDeps = { + services: PluginServices + logger: Logger & { child(bindings: object): Logger } + app: Readonly<{ name: string; publicUrl: string }> +} + +export type LoadOptions = { + /** Skip a failing plugin and report it here, instead of failing the whole load. */ + onFailure?: (index: number, error: Error) => void +} + const ID_PATTERN = /^[a-z0-9-]+$/ -/** Check every plugin, then call each setup once, in config order. */ -export async function loadPlugins( - plugins: Plugin[], - deps: { - services: PluginServices - logger: Logger & { child(bindings: object): Logger } - app: { name: string; publicUrl: string } - }, -): Promise { - const seen = new Set() - for (const plugin of plugins) { - if (!ID_PATTERN.test(plugin.id)) - throw new PluginLoadError(`Plugin ID "${plugin.id}" must match ${ID_PATTERN}`) - if (seen.has(plugin.id)) throw new PluginLoadError(`Two plugins use the ID "${plugin.id}"`) - seen.add(plugin.id) - if (plugin.apiVersion !== PLUGIN_API_VERSION) { - throw new PluginLoadError( - `Plugin "${plugin.id}" targets plugin API version ${plugin.apiVersion}, but this server supports version ${PLUGIN_API_VERSION}`, - ) +type Closer = () => void | Promise + +/** What a plugin registered during setup, held until setup finishes. */ +type Staged = { + providers: ModelProvider[] + tools: Tool[] + toolSources: ToolSource[] + roleSources: RoleSource[] + ingesters: Ingester[] + hooks: { name: HookName; handler: HookHandler; order?: HookOrder }[] + closers: Closer[] +} + +function checkPlugin(plugin: Plugin, loaded: Set) { + if (!ID_PATTERN.test(plugin.id)) + throw new PluginLoadError(`Plugin ID "${plugin.id}" must match ${ID_PATTERN}`) + if (loaded.has(plugin.id)) throw new PluginLoadError(`Two plugins use the ID "${plugin.id}"`) + if (plugin.apiVersion !== PLUGIN_API_VERSION) { + throw new PluginLoadError( + `Plugin "${plugin.id}" targets plugin API version ${plugin.apiVersion}, but this server supports version ${PLUGIN_API_VERSION}`, + ) + } +} + +async function runClosers(closers: Closer[], logger: Logger) { + for (const fn of [...closers].reverse()) { + try { + await fn() + } catch (err) { + logger.warn({ err }, 'plugin cleanup failed') } } +} +/** Check every plugin and call each setup once, in order. A plugin's registrations count only once its setup finishes. */ +export async function loadPlugins( + plugins: Plugin[], + deps: LoadDeps, + options: LoadOptions = {}, +): Promise { + const loaded: Plugin[] = [] + const closers: Closer[] = [] const host: PluginHost = { - plugins, + plugins: loaded, providers: new Registry('provider', (provider) => provider.id), tools: new Registry('tool', (tool) => tool.name), toolSources: new Registry('tool source', (source) => source.id), + roleSources: new Registry('role source', (source) => source.id), ingesters: new IngesterRegistry(), hooks: new HookRunner(), - close: async () => { - for (const fn of closers.reverse()) await fn() - }, + close: () => runClosers(closers, deps.logger), } - const closers: (() => void | Promise)[] = [] for (const [position, plugin] of plugins.entries()) { + const staged: Staged = { + providers: [], + tools: [], + toolSources: [], + roleSources: [], + ingesters: [], + hooks: [], + closers: [], + } const ctx: PluginContext = { - providers: { - register: (provider: ModelProvider) => host.providers.register(provider, plugin.id), - }, - tools: { register: (tool) => host.tools.register(tool as Tool, plugin.id) }, - toolSources: { - register: (source: ToolSource) => host.toolSources.register(source, plugin.id), + providers: { register: (provider) => void staged.providers.push(provider) }, + tools: { register: (tool) => void staged.tools.push(tool as Tool) }, + toolSources: { register: (source) => void staged.toolSources.push(source) }, + roleSources: { register: (source) => void staged.roleSources.push(source) }, + accounts: { + suspend: (did, suspension) => + deps.services.suspendAccount(did, `plugin:${plugin.id}`, suspension?.reason), + restore: (did) => deps.services.restoreAccount(did, `plugin:${plugin.id}`), }, ingesters: { - register: (ingester: Ingester) => host.ingesters.register(ingester, plugin.id), + register: (ingester) => void staged.ingesters.push(ingester), accepts: (mimeType) => host.ingesters.match(mimeType) !== undefined, ingest: async (file) => host.ingesters.match(file.mimeType)?.ingest(file), }, hooks: { - on: (name, handler, options) => - host.hooks.add(name, plugin.id, position, handler, options?.order), + on: (name, handler, hookOptions) => + void staged.hooks.push({ + name, + handler: handler as HookHandler, + order: hookOptions?.order, + }), }, models: { generateText: (request) => deps.services.generateText(request) }, conversations: { updateInfo: (...args) => deps.services.updateInfo(...args) }, userSettings: (user) => deps.services.userSettings(plugin, user), logger: deps.logger.child({ plugin: plugin.id }), - app: Object.freeze({ ...deps.app }), - onClose: (fn) => closers.push(fn), + app: deps.app, + onClose: (fn) => void staged.closers.push(fn), + } + try { + checkPlugin(plugin, new Set(loaded.map((p) => p.id))) + await plugin.setup(ctx) + host.providers.assertFree(staged.providers, plugin.id) + host.tools.assertFree(staged.tools, plugin.id) + host.toolSources.assertFree(staged.toolSources, plugin.id) + host.roleSources.assertFree(staged.roleSources, plugin.id) + host.ingesters.assertFree(staged.ingesters, plugin.id) + } catch (err) { + await runClosers(staged.closers, deps.logger) + if (!options.onFailure) { + await host.close() + throw err + } + options.onFailure(position, err instanceof Error ? err : new Error(String(err))) + continue } - await plugin.setup(ctx) + for (const provider of staged.providers) host.providers.register(provider, plugin.id) + for (const tool of staged.tools) host.tools.register(tool, plugin.id) + for (const source of staged.toolSources) host.toolSources.register(source, plugin.id) + for (const source of staged.roleSources) host.roleSources.register(source, plugin.id) + for (const ingester of staged.ingesters) host.ingesters.register(ingester, plugin.id) + for (const hook of staged.hooks) + host.hooks.add(hook.name, plugin.id, position, hook.handler as never, hook.order) + closers.push(...staged.closers) + loaded.push(plugin) } deps.logger.info({ hooks: host.hooks.describe() }, 'plugins loaded') diff --git a/apps/server/src/plugins/import.ts b/apps/server/src/plugins/import.ts deleted file mode 100644 index 4e460a7..0000000 --- a/apps/server/src/plugins/import.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { existsSync, readFileSync, statSync } from 'node:fs' -import { createRequire } from 'node:module' -import { isAbsolute, join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import type { Plugin } from '@scn-chat/plugin-api' -import type { z } from 'zod' -import type { PluginEntry } from '../config.ts' -import { PluginLoadError } from './host.ts' - -type PluginModule = { default?: unknown; optionsSchema?: z.ZodType } - -type PackageJson = { main?: string; exports?: string | Record } - -/** A local plugin directory's entry file, from its package.json exports or main. */ -function localEntry(dir: string): string | undefined { - const manifest = join(dir, 'package.json') - if (!existsSync(manifest)) return undefined - const pkg = JSON.parse(readFileSync(manifest, 'utf8')) as PackageJson - const root = typeof pkg.exports === 'object' ? pkg.exports['.'] : pkg.exports - const target = - typeof root === 'string' - ? root - : ((root as Record | undefined)?.import ?? - (root as Record | undefined)?.default) - const entry = target ?? pkg.main ?? 'index.js' - return join(dir, entry) -} - -function resolvePackage(specifier: string, configDir: string): string { - if (specifier.startsWith('.') || isAbsolute(specifier)) { - const path = resolve(configDir, specifier) - if (existsSync(path) && statSync(path).isDirectory()) { - const entry = localEntry(path) - if (entry && existsSync(entry)) return entry - } else if (existsSync(path)) return path - throw new Error(`not found at ${path}`) - } - return createRequire(join(configDir, 'config.yml')).resolve(specifier) -} - -/** Import each configured plugin package, validate its options, and call its factory, in config order. */ -export async function importPlugins(entries: PluginEntry[], configDir: string): Promise { - const plugins: Plugin[] = [] - for (const entry of entries) { - let resolved: string - try { - resolved = resolvePackage(entry.package, configDir) - } catch (err) { - throw new PluginLoadError( - `Cannot load plugin package "${entry.package}" from ${configDir}: ${err instanceof Error ? err.message : String(err)}`, - { cause: err }, - ) - } - const module = (await import(pathToFileURL(resolved).href)) as PluginModule - if (typeof module.default !== 'function') { - throw new PluginLoadError(`Plugin package "${entry.package}" has no default export factory`) - } - let options: unknown = entry.options ?? {} - if (module.optionsSchema) { - const result = module.optionsSchema.safeParse(options) - if (!result.success) { - const problems = result.error.issues.map( - (issue) => `${issue.path.join('.') || 'options'}: ${issue.message}`, - ) - throw new PluginLoadError( - `Plugin "${entry.package}" has invalid options: ${problems.join('; ')}`, - ) - } - options = result.data - } - plugins.push((module.default as (options: unknown) => Plugin)(options)) - } - return plugins -} diff --git a/apps/server/src/plugins/installed.ts b/apps/server/src/plugins/installed.ts new file mode 100644 index 0000000..d0a722a --- /dev/null +++ b/apps/server/src/plugins/installed.ts @@ -0,0 +1,80 @@ +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { Logger, Plugin } from '@scn-chat/plugin-api' +import { z } from 'zod' + +/** The package.json keyword that marks a dependency as an SCN Chat plugin. */ +export const PLUGIN_KEYWORD = 'scn-chat-plugin' + +export type InstalledPlugin = { + package: string + description: string | null + version: string | null + factory: (options: unknown) => Plugin + optionsSchema?: z.ZodType + /** Whether admins can add the package more than once, with different options. */ + multiple: boolean +} + +export type InstalledPlugins = ReadonlyMap + +type PackageJson = { + description?: string + version?: string + keywords?: string[] + dependencies?: Record +} + +type PluginModule = { default?: unknown; optionsSchema?: z.ZodType; multipleInstances?: unknown } + +const readJson = (path: string) => JSON.parse(readFileSync(path, 'utf8')) as PackageJson + +/** Import every dependency of the root package.json that carries the plugin keyword. */ +export async function findInstalledPlugins( + rootDir: string, + logger: Logger, +): Promise { + const root = join(rootDir, 'package.json') + const require = createRequire(root) + const installed = new Map() + for (const name of Object.keys(readJson(root).dependencies ?? {})) { + const manifest = join(rootDir, 'node_modules', name, 'package.json') + if (!existsSync(manifest)) continue + const pkg = readJson(manifest) + if (!pkg.keywords?.includes(PLUGIN_KEYWORD)) continue + const module = (await import(pathToFileURL(require.resolve(name)).href)) as PluginModule + if (typeof module.default !== 'function') { + logger.error({ package: name }, 'plugin package has no default export factory') + continue + } + installed.set(name, { + package: name, + description: pkg.description ?? null, + version: pkg.version ?? null, + factory: module.default as (options: unknown) => Plugin, + ...(module.optionsSchema ? { optionsSchema: module.optionsSchema } : {}), + multiple: module.multipleInstances === true, + }) + } + return installed +} + +/** Top-level fields of a schema marked with .meta({ secret: true }). */ +export function secretKeys(schema: z.ZodType | undefined): string[] { + if (!(schema instanceof z.ZodObject)) return [] + return Object.entries(schema.shape) + .filter(([, field]) => (field as z.ZodType).meta()?.secret === true) + .map(([key]) => key) +} + +/** The plugin's options as JSON Schema, for the admin form. */ +export function optionsJsonSchema(plugin: InstalledPlugin): Record { + return plugin.optionsSchema + ? (z.toJSONSchema(plugin.optionsSchema, { io: 'input', unrepresentable: 'any' }) as Record< + string, + unknown + >) + : { type: 'object', properties: {} } +} diff --git a/apps/server/src/plugins/instances.ts b/apps/server/src/plugins/instances.ts new file mode 100644 index 0000000..70018ec --- /dev/null +++ b/apps/server/src/plugins/instances.ts @@ -0,0 +1,59 @@ +import type { Db } from '../db/index.ts' +import type { SecretBox } from '../secrets.ts' + +/** A configured plugin, with its options split into plain values and secrets. */ +export type PluginInstance = { + id: string + package: string + enabled: boolean + options: Record + secrets: Record +} + +/** Every plugin instance, in load order, with secrets decrypted. */ +export async function readInstances(db: Db, box: SecretBox): Promise { + const rows = await db.selectFrom('plugin_instance').selectAll().orderBy('position').execute() + return rows.map((row) => ({ + id: row.id, + package: row.package, + enabled: row.enabled === 1, + options: JSON.parse(row.options_json) as Record, + secrets: row.secrets_encrypted + ? (JSON.parse(box.decrypt(row.secrets_encrypted)) as Record) + : {}, + })) +} + +/** Replace the stored instances with this list, in this order. */ +export async function writeInstances( + db: Db, + box: SecretBox, + instances: PluginInstance[], + adminDid: string, +): Promise { + const now = new Date().toISOString() + await db.transaction().execute(async (tx) => { + const ids = instances.map((instance) => instance.id) + let removal = tx.deleteFrom('plugin_instance') + if (ids.length) removal = removal.where('id', 'not in', ids) + await removal.execute() + for (const [position, instance] of instances.entries()) { + const row = { + package: instance.package, + position, + enabled: instance.enabled ? 1 : 0, + options_json: JSON.stringify(instance.options), + secrets_encrypted: Object.keys(instance.secrets).length + ? box.encrypt(JSON.stringify(instance.secrets)) + : null, + updated_at: now, + updated_by: adminDid, + } + await tx + .insertInto('plugin_instance') + .values({ id: instance.id, created_at: now, ...row }) + .onConflict((oc) => oc.column('id').doUpdateSet(row)) + .execute() + } + }) +} diff --git a/apps/server/src/plugins/registry.ts b/apps/server/src/plugins/registry.ts index 37879db..a9e3dae 100644 --- a/apps/server/src/plugins/registry.ts +++ b/apps/server/src/plugins/registry.ts @@ -27,6 +27,19 @@ export class Registry { this.items.set(key, { item, pluginId }) } + /** Throw if these items would clash with each other or with ones already registered. */ + assertFree(items: T[], pluginId: string): void { + const seen = new Set() + for (const item of items) { + const key = this.keyOf(item) + const existing = this.items.get(key) + if (existing) + throw new DuplicateRegistrationError(this.kind, key, pluginId, existing.pluginId) + if (seen.has(key)) throw new DuplicateRegistrationError(this.kind, key, pluginId, pluginId) + seen.add(key) + } + } + get(key: string): T | undefined { return this.items.get(key)?.item } diff --git a/apps/server/src/plugins/routes.ts b/apps/server/src/plugins/routes.ts index 4b1cfb2..c233ce5 100644 --- a/apps/server/src/plugins/routes.ts +++ b/apps/server/src/plugins/routes.ts @@ -17,19 +17,27 @@ import { } from './user-settings.ts' import { isToolEnabled, readToolChoices, writeToolChoice } from './user-tools.ts' -export type PluginRoutesDeps = { db: Db; box: SecretBox; host: PluginHost } +export type PluginRoutesDeps = { + db: Db + box: SecretBox + /** The current plugin runtime's host. */ + host: () => PluginHost +} /** Per-user plugin settings and tool switches: forms, saving, and resetting. */ export function pluginRoutes(deps: PluginRoutesDeps) { - const { tools } = deps.host - const toolsOf = (plugin: Plugin) => - tools.list().filter((tool) => tools.owner(tool.name) === plugin.id) + const toolsOf = (plugin: Plugin) => { + const { tools } = deps.host() + return tools.list().filter((tool) => tools.owner(tool.name) === plugin.id) + } const listed = () => - deps.host.plugins.filter( - (plugin) => plugin.userSettings || toolsOf(plugin).some((tool) => tool.userToggle), - ) + deps + .host() + .plugins.filter( + (plugin) => plugin.userSettings || toolsOf(plugin).some((tool) => tool.userToggle), + ) const find = (id: string) => - deps.host.plugins.find((plugin) => plugin.id === id && plugin.userSettings) + deps.host().plugins.find((plugin) => plugin.id === id && plugin.userSettings) /** The plugin's settings form and the user's values, with secrets blanked. */ const settingsOf = async (plugin: Plugin, did: string) => { @@ -74,6 +82,7 @@ export function pluginRoutes(deps: PluginRoutesDeps) { }) .put('/:id/tools/:name', async (c) => { const name = c.req.param('name') + const { tools } = deps.host() const tool = tools.get(name) if (!tool || tools.owner(name) !== c.req.param('id')) return c.json({ error: 'NotFound' }, 404) diff --git a/apps/server/src/plugins/runtime.ts b/apps/server/src/plugins/runtime.ts new file mode 100644 index 0000000..3f5f2f0 --- /dev/null +++ b/apps/server/src/plugins/runtime.ts @@ -0,0 +1,178 @@ +import type { Logger, Plugin } from '@scn-chat/plugin-api' +import { describeIssues, type Issue, issuesOf } from '../body.ts' +import type { ModelCatalog } from '../providers/catalog.ts' +import { safeErrorMessage } from '../safe-error.ts' +import { type LoadDeps, loadPlugins, type PluginHost } from './host.ts' +import type { InstalledPlugins } from './installed.ts' +import type { PluginInstance } from './instances.ts' + +export type InstanceStatus = + | { state: 'loaded'; pluginId: string; name: string } + | { state: 'disabled' } + | { state: 'failed'; error: string; issues: Issue[] } + +/** Everything built from the plugin instances: the plugin host and the model catalog over it. */ +export type PluginRuntime = { + host: PluginHost + catalog: ModelCatalog + statuses: ReadonlyMap +} + +/** A plugin instance that can't be loaded, with the option fields at fault when there are any. */ +export class PluginInstanceError extends Error { + readonly instanceId: string + readonly issues: Issue[] + + constructor(instanceId: string, message: string, issues: Issue[] = []) { + super(message) + this.name = 'PluginInstanceError' + this.instanceId = instanceId + this.issues = issues + } +} + +export type RuntimeBuilder = (instances: PluginInstance[]) => Promise + +export type RuntimeBuilderDeps = { + installed: InstalledPlugins + load: LoadDeps + catalog: (host: PluginHost) => ModelCatalog + logger: Logger +} + +/** Validate an instance's options and call its plugin's factory. */ +export function instantiate(installed: InstalledPlugins, instance: PluginInstance): Plugin { + const found = installed.get(instance.package) + if (!found) + throw new PluginInstanceError( + instance.id, + `The plugin package "${instance.package}" isn't installed`, + ) + let options: unknown = { ...instance.options, ...instance.secrets } + if (found.optionsSchema) { + const result = found.optionsSchema.safeParse(options) + if (!result.success) { + const issues = issuesOf(result.error) + throw new PluginInstanceError( + instance.id, + `Invalid options for "${instance.package}": ${describeIssues(issues, 'options')}`, + issues, + ) + } + options = result.data + } + try { + return found.factory(options) + } catch (err) { + throw new PluginInstanceError( + instance.id, + `"${instance.package}" failed to start: ${safeErrorMessage(err)}`, + ) + } +} + +/** Build a runtime from plugin instances, skipping any that fail and recording why. */ +export function runtimeBuilder(deps: RuntimeBuilderDeps): RuntimeBuilder { + return async (instances) => { + const statuses = new Map() + const plugins: Plugin[] = [] + const owners: string[] = [] + const fail = (err: PluginInstanceError) => { + deps.logger.warn({ err, instance: err.instanceId }, 'skipping a plugin that failed to load') + statuses.set(err.instanceId, { state: 'failed', error: err.message, issues: err.issues }) + } + for (const instance of instances) { + if (!instance.enabled) { + statuses.set(instance.id, { state: 'disabled' }) + continue + } + try { + plugins.push(instantiate(deps.installed, instance)) + owners.push(instance.id) + } catch (err) { + if (!(err instanceof PluginInstanceError)) throw err + fail(err) + } + } + const failures: PluginInstanceError[] = [] + const host = await loadPlugins(plugins, deps.load, { + onFailure: (index, error) => + failures.push( + new PluginInstanceError( + owners[index] as string, + `Plugin "${plugins[index]?.id}" failed to load: ${safeErrorMessage(error)}`, + ), + ), + }) + for (const failure of failures) fail(failure) + for (const plugin of host.plugins) { + const owner = owners[plugins.indexOf(plugin)] as string + statuses.set(owner, { state: 'loaded', pluginId: plugin.id, name: plugin.name }) + } + return { host, catalog: deps.catalog(host), statuses } + } +} + +export type RuntimeLease = { runtime: PluginRuntime; release(): void } + +/** Holds the current plugin runtime, and closes replaced ones once nothing uses them. */ +export class RuntimeHolder { + private runtime: PluginRuntime + private readonly leases = new Map() + private readonly retiring = new Set() + private queue: Promise = Promise.resolve() + private readonly logger: Logger + + constructor(runtime: PluginRuntime, logger: Logger) { + this.runtime = runtime + this.logger = logger + } + + current(): PluginRuntime { + return this.runtime + } + + /** Hold the current runtime until release, so a swap doesn't close it mid-use. */ + acquire(): RuntimeLease { + const runtime = this.runtime + this.leases.set(runtime, (this.leases.get(runtime) ?? 0) + 1) + let released = false + return { + runtime, + release: () => { + if (released) return + released = true + const left = (this.leases.get(runtime) ?? 1) - 1 + if (left > 0) return void this.leases.set(runtime, left) + this.leases.delete(runtime) + if (this.retiring.has(runtime)) this.closeRuntime(runtime) + }, + } + } + + /** Make the runtime current. The old one closes when its last lease is released. */ + swap(next: PluginRuntime): void { + const old = this.runtime + this.runtime = next + if (this.leases.has(old)) this.retiring.add(old) + else this.closeRuntime(old) + } + + /** Run changes one at a time. */ + exclusive(fn: () => Promise): Promise { + const run = this.queue.then(fn, fn) + this.queue = run.catch(() => {}) + return run + } + + async close(): Promise { + await this.runtime.host.close() + } + + private closeRuntime(runtime: PluginRuntime) { + this.retiring.delete(runtime) + runtime.host + .close() + .catch((err) => this.logger.warn({ err }, 'closing a replaced plugin runtime failed')) + } +} diff --git a/apps/server/src/plugins/user-settings.ts b/apps/server/src/plugins/user-settings.ts index 83c0628..2903403 100644 --- a/apps/server/src/plugins/user-settings.ts +++ b/apps/server/src/plugins/user-settings.ts @@ -1,7 +1,7 @@ import type { Plugin } from '@scn-chat/plugin-api' -import type { z } from 'zod' import type { Db } from '../db/index.ts' import type { SecretBox } from '../secrets.ts' +import { secretKeys } from './installed.ts' export class InvalidPluginSettingsError extends Error { readonly pluginId: string @@ -13,14 +13,7 @@ export class InvalidPluginSettingsError extends Error { } } -type SettingsSchema = z.ZodObject - -/** Top-level fields marked with .meta({ secret: true }). */ -export function secretKeys(schema: SettingsSchema): string[] { - return Object.entries(schema.shape) - .filter(([, field]) => (field as z.ZodType).meta()?.secret === true) - .map(([key]) => key) -} +export { secretKeys } async function readRaw(db: Db, box: SecretBox, did: string, pluginId: string) { const row = await db diff --git a/apps/server/src/providers/admin-models.ts b/apps/server/src/providers/admin-models.ts new file mode 100644 index 0000000..82e7210 --- /dev/null +++ b/apps/server/src/providers/admin-models.ts @@ -0,0 +1,119 @@ +import type { Capabilities, ModelProvider } from '@scn-chat/plugin-api' +import { z } from 'zod' +import type { Issue } from '../body.ts' +import type { Db } from '../db/index.ts' +import type { Registry } from '../plugins/registry.ts' + +/** A model everyone in its roles can use with the admin's key. */ +export type AdminModel = { + provider: string + id: string + name: string + capabilities: Capabilities + roles: string[] + default: boolean +} + +export const capabilitiesSchema = z.object({ + vision: z.boolean(), + reasoning: z.boolean(), + tools: z.boolean(), +}) + +export const adminModelSchema = z.object({ + provider: z.string().min(1), + id: z.string().min(1), + name: z.string().trim().min(1), + capabilities: capabilitiesSchema, + roles: z.array(z.string()).min(1, 'List at least one role'), + default: z.boolean().default(false), +}) + +/** Can the provider serve admin models? */ +export const servesAdminModels = (providers: Registry, id: string) => + providers.get(id)?.hasAdminKey === true + +/** Problems with an admin model against the loaded providers and existing roles. */ +export function adminModelIssues( + model: AdminModel, + providers: Registry, + roleNames: ReadonlySet, +): Issue[] { + const issues: Issue[] = [] + const provider = providers.get(model.provider) + if (!provider) + issues.push({ path: ['provider'], message: `No plugin registers provider "${model.provider}"` }) + else if (!provider.hasAdminKey) + issues.push({ path: ['provider'], message: `Provider "${model.provider}" has no admin key` }) + for (const role of model.roles) { + if (!roleNames.has(role)) issues.push({ path: ['roles'], message: `No such role: ${role}` }) + } + return issues +} + +export async function listAdminModels(db: Db): Promise { + const rows = await db.selectFrom('admin_model').selectAll().orderBy('position').execute() + return rows.map((row) => ({ + provider: row.provider, + id: row.model_id, + name: row.name, + capabilities: JSON.parse(row.capabilities_json) as Capabilities, + roles: JSON.parse(row.roles_json) as string[], + default: row.is_default === 1, + })) +} + +/** Add or change a model. Marking it default clears the flag on every other model. */ +export async function saveAdminModel(db: Db, model: AdminModel, adminDid: string) { + const now = new Date().toISOString() + await db.transaction().execute(async (tx) => { + if (model.default) await tx.updateTable('admin_model').set({ is_default: 0 }).execute() + const last = await tx + .selectFrom('admin_model') + .select((eb) => eb.fn.max('position').as('position')) + .executeTakeFirst() + const row = { + name: model.name, + capabilities_json: JSON.stringify(model.capabilities), + roles_json: JSON.stringify(model.roles), + is_default: model.default ? 1 : 0, + updated_at: now, + updated_by: adminDid, + } + await tx + .insertInto('admin_model') + .values({ + provider: model.provider, + model_id: model.id, + position: Number(last?.position ?? -1) + 1, + created_at: now, + ...row, + }) + .onConflict((oc) => oc.columns(['provider', 'model_id']).doUpdateSet(row)) + .execute() + }) +} + +/** Returns false when there was no such model. */ +export async function deleteAdminModel(db: Db, provider: string, id: string): Promise { + const result = await db + .deleteFrom('admin_model') + .where('provider', '=', provider) + .where('model_id', '=', id) + .executeTakeFirst() + return result.numDeletedRows > 0n +} + +/** Put the models in this order. */ +export async function reorderAdminModels(db: Db, refs: { provider: string; id: string }[]) { + await db.transaction().execute(async (tx) => { + for (const [position, ref] of refs.entries()) { + await tx + .updateTable('admin_model') + .set({ position }) + .where('provider', '=', ref.provider) + .where('model_id', '=', ref.id) + .execute() + } + }) +} diff --git a/apps/server/src/providers/catalog.ts b/apps/server/src/providers/catalog.ts index 111c076..4473624 100644 --- a/apps/server/src/providers/catalog.ts +++ b/apps/server/src/providers/catalog.ts @@ -1,19 +1,11 @@ import type { Capabilities, LanguageModelV4, ModelProvider, ModelRef } from '@scn-chat/plugin-api' -import type { Roles } from '../auth/roles.ts' -import type { ModelConfig } from '../config.ts' import type { Db } from '../db/index.ts' import type { Logger } from '../logger.ts' import type { Registry } from '../plugins/registry.ts' import type { SecretBox } from '../secrets.ts' +import { type AdminModel, listAdminModels, servesAdminModels } from './admin-models.ts' import { credentialFor, listCredentials, USER_PROVIDER_PREFIX } from './user-credentials.ts' -export class ProviderConfigError extends Error { - constructor(message: string) { - super(message) - this.name = 'ProviderConfigError' - } -} - export class ModelUnavailable extends Error { readonly ref: ModelRef @@ -40,41 +32,12 @@ export type ResolvedModel = { capabilities: Capabilities } -/** Check the admin models against the registered providers and defined roles. */ -export function validateAdminModels( - models: ModelConfig[], - registry: Registry, - roles: Roles, -): ModelConfig[] { - for (const model of models) { - const name = `${model.provider}/${model.id}` - const provider = registry.get(model.provider) - if (!provider) - throw new ProviderConfigError( - `Admin model ${name} names provider "${model.provider}", which no plugin registered`, - ) - if (!provider.hasAdminKey) - throw new ProviderConfigError( - `Admin model ${name} uses provider "${model.provider}", which has no admin key`, - ) - if (!Array.isArray(model.roles) || model.roles.length === 0) { - throw new ProviderConfigError(`Admin model ${name} must list the roles allowed to use it`) - } - const undefinedRole = model.roles.find((role) => !roles.defined.has(role)) - if (undefinedRole) - throw new ProviderConfigError(`Admin model ${name} names undefined role "${undefinedRole}"`) - } - if (models.filter((model) => model.default).length > 1) - throw new ProviderConfigError('More than one admin model is marked default') - return models -} - export type CatalogDeps = { db: Db box: SecretBox providers: Registry - adminModels: ModelConfig[] - roles: Roles + /** The user's roles, which decide the admin models they may use. */ + rolesOf: (did: string) => Promise guardedFetch: typeof fetch logger: Logger } @@ -87,19 +50,28 @@ export class ModelCatalog { this.deps = deps } - private adminModelsFor(did: string): ModelConfig[] { - const userRoles = new Set(this.deps.roles.rolesFor(did)) - return this.deps.adminModels.filter((model) => model.roles.some((role) => userRoles.has(role))) + /** Admin models whose provider is loaded with an admin key. */ + private async availableAdminModels(): Promise { + return (await listAdminModels(this.deps.db)).filter((model) => + servesAdminModels(this.deps.providers, model.provider), + ) + } + + private async adminModelsFor(did: string): Promise { + const userRoles = new Set(await this.deps.rolesOf(did)) + return (await this.availableAdminModels()).filter((model) => + model.roles.some((role) => userRoles.has(role)), + ) } - defaultModel(): ModelRef | undefined { - const model = this.deps.adminModels.find((m) => m.default) + async defaultModel(): Promise { + const model = (await this.availableAdminModels()).find((m) => m.default) return model ? { provider: model.provider, id: model.id } : undefined } async listForUser(did: string): Promise { const listed = new Map() - for (const model of this.adminModelsFor(did)) { + for (const model of await this.adminModelsFor(did)) { listed.set(`${model.provider}/${model.id}`, { provider: model.provider, id: model.id, @@ -146,7 +118,7 @@ export class ModelCatalog { }) return { ref, provider, model, capabilities: own.info.capabilities } } - const admin = this.adminModelsFor(did).find( + const admin = (await this.adminModelsFor(did)).find( (m) => m.provider === ref.provider && m.id === ref.id, ) if (!admin) throw new ModelUnavailable(ref) diff --git a/apps/server/src/providers/routes.ts b/apps/server/src/providers/routes.ts index b6cd44b..fa4575f 100644 --- a/apps/server/src/providers/routes.ts +++ b/apps/server/src/providers/routes.ts @@ -19,8 +19,9 @@ import { export type ProviderRoutesDeps = { db: Db box: SecretBox - catalog: ModelCatalog - providers: Registry + /** The current plugin runtime's catalog and providers. */ + catalog: () => ModelCatalog + providers: () => Registry guardedFetch: typeof fetch logger: Logger } @@ -46,7 +47,7 @@ const listModelsRequest = z.object({ /** Models, providers that accept user keys, and the user's own credentials. */ export function providerRoutes(deps: ProviderRoutesDeps) { const userProvider = (id: string) => { - const provider = deps.providers.get(id) + const provider = deps.providers().get(id) return provider?.userKeys ? provider : undefined } @@ -55,13 +56,14 @@ export function providerRoutes(deps: ProviderRoutesDeps) { .get('/models', async (c) => { const { did } = signedInUser(c) return c.json({ - models: await deps.catalog.listForUser(did), - defaultModel: deps.catalog.defaultModel() ?? null, + models: await deps.catalog().listForUser(did), + defaultModel: (await deps.catalog().defaultModel()) ?? null, }) }) .get('/providers', (c) => c.json({ - providers: deps.providers + providers: deps + .providers() .list() .filter((provider) => provider.userKeys) .map((provider) => ({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7551f80..a292bbd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,24 +1,29 @@ -import type { Plugin } from '@scn-chat/plugin-api' import { createApp } from './app.ts' +import { Access } from './auth/access.ts' import { type Account, getAccount } from './auth/accounts.ts' import { createIdentityResolver, type IdentityResolver } from './auth/identity.ts' import { createOAuthClient, type OAuthClientLike } from './auth/oauth-client.ts' import { createPdsClientFactory } from './auth/pds.ts' -import { createRoles } from './auth/roles.ts' +import { Roles } from './auth/roles.ts' import { buildScope } from './auth/scope.ts' import { sweepExpired } from './auth/web-session.ts' import { turnBlobs } from './blobs/routes.ts' import { createBlobStores } from './blobs/store.ts' -import type { Config, ModelConfig, SyncConfig } from './config.ts' +import type { Config } from './config.ts' import type { Db } from './db/index.ts' import { migrateToLatest } from './db/migrate.ts' import type { Logger } from './logger.ts' -import { loadPlugins, type PluginServices } from './plugins/host.ts' +import { PluginAdmin } from './plugins/admin.ts' +import type { PluginServices } from './plugins/host.ts' +import type { InstalledPlugins } from './plugins/installed.ts' +import { readInstances } from './plugins/instances.ts' +import { RuntimeHolder, runtimeBuilder } from './plugins/runtime.ts' import { readUserSettings } from './plugins/user-settings.ts' -import { ModelCatalog, validateAdminModels } from './providers/catalog.ts' +import { ModelCatalog } from './providers/catalog.ts' import { createGenerateText } from './providers/generate-text.ts' import { createGuardedFetch } from './providers/guarded-fetch.ts' import { SecretBox } from './secrets.ts' +import { SettingsStore } from './settings/store.ts' import { SharingService } from './sharing/service.ts' import { parseSpaceUri } from './storage/records.ts' import { createChatServices } from './storage/services.ts' @@ -26,22 +31,16 @@ import { CredentialCache, mintSpaceCredential } from './sync/credentials.ts' import { SyncEngine } from './sync/engine.ts' import { SyncEventBus } from './sync/events.ts' import { RecentWrites } from './sync/recent-writes.ts' -import { resolveSyncConfig, startSyncScheduler } from './sync/scheduler.ts' +import { startSyncScheduler } from './sync/scheduler.ts' import { TurnRunner } from './turns/runner.ts' import { StreamHub } from './turns/stream-hub.ts' -export type AppConfig = { - plugins: Plugin[] - models?: ModelConfig[] - roles?: Record - sync?: SyncConfig -} - export type ServerDeps = { config: Config - appConfig: AppConfig db: Db logger: Logger + /** The plugin packages admins can add. */ + installed: InstalledPlugins webDist?: string /** Replacements for network-facing pieces, for tests. */ oauth?: OAuthClientLike @@ -49,14 +48,15 @@ export type ServerDeps = { startBackgroundJobs?: boolean } -/** Build every service and the HTTP app from config. */ +/** Build every service and the HTTP app from the environment and the stored settings. */ export async function createServer(deps: ServerDeps) { - const { config, appConfig, db, logger } = deps + const { config, db, logger } = deps await migrateToLatest(db) - const roles = createRoles(appConfig.roles) + const settings = await SettingsStore.load(db, logger) + const appName = () => settings.get('general').appName const box = new SecretBox(config.secretKey, config.oldSecretKeys) const scope = buildScope(config.oauthScopeMode) - const oauth = deps.oauth ?? (await createOAuthClient(config, db, scope)) + const oauth = deps.oauth ?? (await createOAuthClient(config, db, scope, appName())) const getPdsClient = createPdsClientFactory(oauth) const identity = deps.identity ?? @@ -68,35 +68,73 @@ export async function createServer(deps: ServerDeps) { const blobStores = createBlobStores({ getPdsClient, dataDir: config.dataDir, db }) const guardedFetch = createGuardedFetch() + // Set once the plugins load, which needs the services below. + let holder: RuntimeHolder | undefined + const runtime = () => { + if (!holder) throw new Error('Plugins are not ready yet') + return holder.current() + } + const roles = new Roles({ + db, + adminDids: new Set(config.adminDids), + sources: () => (holder ? holder.current().host.roleSources.list() : []), + logger, + }) + const access = new Access({ db, roles, settings, logger }) + const hasAccess = (did: string) => access.hasAccess(did) + const accountFor = async (did: string): Promise => { const account = await getAccount(db, did) if (!account) throw new Error(`No account for ${did}`) return account } - let catalog: ModelCatalog | undefined const pluginServices: PluginServices = { - generateText: (request) => { - if (!catalog) throw new Error('Models are not ready yet') - return createGenerateText(catalog, logger)(request) - }, + generateText: (request) => createGenerateText(runtime().catalog, logger)(request), updateInfo: async (user, conversation, patch, options) => { const chats = services.forAccount(await accountFor(user)) await chats.updateInfo(parseSpaceUri(conversation).skey, patch, options) }, userSettings: (plugin, user) => readUserSettings(db, box, user, plugin), + suspendAccount: (did, by, reason) => access.suspend(did, by, reason), + restoreAccount: (did, by) => access.restore(did, by), } - const host = await loadPlugins(appConfig.plugins, { + const load = { services: pluginServices, logger, - app: { name: config.appName, publicUrl: config.publicUrl }, + app: Object.freeze({ + get name() { + return appName() + }, + publicUrl: config.publicUrl, + }), + } + const build = runtimeBuilder({ + installed: deps.installed, + load, + catalog: (host) => + new ModelCatalog({ + db, + box, + providers: host.providers, + rolesOf: (did) => access.rolesForDid(did), + guardedFetch, + logger, + }), + logger, }) - catalog = new ModelCatalog({ + const initial = await build(await readInstances(db, box)) + for (const [instance, status] of initial.statuses) { + if (status.state === 'failed') + logger.error({ instance, error: status.error }, 'a plugin failed to load at startup') + } + holder = new RuntimeHolder(initial, logger) + const plugins = new PluginAdmin({ db, box, - providers: host.providers, - adminModels: validateAdminModels(appConfig.models ?? [], host.providers, roles), - roles, - guardedFetch, + installed: deps.installed, + holder, + build, + load, logger, }) @@ -111,20 +149,30 @@ export async function createServer(deps: ServerDeps) { resolveSigningKey: (did) => identity.resolveSigningKey(did), publicUrl: config.publicUrl, logger, - backfillWindowMs: config.turns.backfillWindowMs, + backfillWindowMs: () => settings.get('turns').backfillMinutes * 60_000, + hasAccess, }) - const syncConfig = resolveSyncConfig(appConfig.sync) const hub = new StreamHub() const blobsFor = blobStores.forAccount const runner = new TurnRunner({ db, services, - catalog, - host, + plugins: holder, hub, blobs: turnBlobs(blobsFor, services), - config: config.turns, - appName: config.appName, + settings: () => { + const turns = settings.get('turns') + return { + ratePerMinute: turns.ratePerMinute, + maxSteps: turns.maxSteps, + timeoutMs: turns.timeoutSeconds * 1000, + backfillWindowMs: turns.backfillMinutes * 60_000, + systemPrompt: turns.systemPrompt, + } + }, + appName, + hasAccess, + rolesOf: (did) => access.rolesForDid(did), toolFetch: serviceFetch, logger, }) @@ -158,13 +206,29 @@ export async function createServer(deps: ServerDeps) { config, db, logger, + settings, webDist: deps.webDist, - auth: { config, db, logger, oauth, identity, roles, scope, onLogin }, - plugins: { db, box, host }, - storage: { db, services, events, engine, hooks: host.hooks, syncConfig, logger }, - providers: { db, box, catalog, providers: host.providers, guardedFetch, logger }, + auth: { config, db, logger, oauth, identity, roles, access, settings, scope, onLogin }, + plugins: { db, box, host: () => runtime().host }, + storage: { + db, + services, + events, + engine, + hooks: () => runtime().host.hooks, + sync: () => settings.get('sync'), + logger, + }, + providers: { + db, + box, + catalog: () => runtime().catalog, + providers: () => runtime().host.providers, + guardedFetch, + logger, + }, turns: { services, runner, hub }, - blobs: { blobsFor, services, ingesters: host.ingesters, logger }, + blobs: { blobsFor, services, ingesters: () => runtime().host.ingesters, logger }, sharing, sync: { db, @@ -174,12 +238,22 @@ export async function createServer(deps: ServerDeps) { resolveSigningKey: identity.resolveSigningKey, logger, }, + admin: { db, roles, access, settings, identity, plugins, runtime, logger }, }) const stops: (() => void)[] = [] if (deps.startBackgroundJobs !== false) { await runner.recover() - stops.push(startSyncScheduler({ engine, db, config: syncConfig, logger })) + stops.push( + startSyncScheduler({ + engine, + db, + sync: () => settings.get('sync'), + onSyncChange: (listener) => settings.subscribe('sync', listener), + hasAccess, + logger, + }), + ) const sweepAll = () => { blobStores.local.sweep().catch((err) => logger.warn({ err }, 'blob sweep failed')) sweepExpired(db).catch((err) => logger.warn({ err }, 'session sweep failed')) @@ -193,10 +267,12 @@ export async function createServer(deps: ServerDeps) { app, runner, engine, + settings, + holder, async close() { for (const stop of stops) stop() await runner.idle() - await host.close() + await holder?.close() }, } } diff --git a/apps/server/src/settings/schemas.ts b/apps/server/src/settings/schemas.ts new file mode 100644 index 0000000..5d41660 --- /dev/null +++ b/apps/server/src/settings/schemas.ts @@ -0,0 +1,71 @@ +import { z } from 'zod' + +/** The start of every turn's instructions. Placeholders: {{appName}}, {{date}}, {{time}}, {{timezone}}. */ +export const DEFAULT_SYSTEM_PROMPT = + 'You are an AI assistant in {{appName}}. Today is {{date}} ({{timezone}}). ' + + 'Replies are rendered as Markdown. Raw HTML is not rendered, and images load only when the user clicks them.' + +const minutes = (title: string, fallback: number) => + z.number().positive().default(fallback).meta({ title }) + +/** Admin settings kept in the app_setting table, one schema per key. */ +export const settingSchemas = { + access: z.object({ + registration: z.enum(['open', 'invite', 'closed']).default('open'), + inviteRoles: z.array(z.string()).default([]), + }), + general: z.object({ + appName: z.string().trim().min(1).default('SCN Chat').meta({ title: 'App name' }), + }), + sessions: z.object({ + ttlDays: z.number().int().min(1).default(30).meta({ title: 'Days a web session lasts' }), + }), + turns: z.object({ + ratePerMinute: z.number().int().min(1).default(10).meta({ title: 'Turns per user per minute' }), + maxSteps: z.number().int().min(1).default(8).meta({ title: 'Model steps per turn' }), + timeoutSeconds: z.number().int().min(1).default(600).meta({ title: 'Turn timeout in seconds' }), + backfillMinutes: z.number().int().min(0).default(60).meta({ + title: 'Backfill window in minutes', + description: 'How old a synced message asking for a reply can be and still get one.', + }), + systemPrompt: z.string().default(DEFAULT_SYSTEM_PROMPT).meta({ + title: 'System prompt', + description: 'Placeholders: {{appName}}, {{date}}, {{time}}, {{timezone}}.', + multiline: true, + }), + }), + sync: z.object({ + safetyNet: z + .object({ + enabled: z.boolean().default(true).meta({ title: 'Enabled' }), + intervalMinutes: minutes('Every this many minutes', 15), + activeWithinHours: z + .number() + .positive() + .default(24) + .meta({ title: 'For users active within this many hours' }), + }) + .prefault({}) + .meta({ title: 'Safety net' }), + discovery: z + .object({ + intervalMinutes: minutes('Every this many minutes', 60), + activeWithinDays: z + .number() + .positive() + .default(30) + .meta({ title: 'For users active within this many days' }), + }) + .prefault({}) + .meta({ title: 'Discovery' }), + allowUserOptOut: z + .boolean() + .default(true) + .meta({ title: 'Users can turn off background sync' }), + }), +} + +export type SettingKey = keyof typeof settingSchemas +export type Settings = { [K in SettingKey]: z.infer<(typeof settingSchemas)[K]> } + +export const isSettingKey = (key: string): key is SettingKey => key in settingSchemas diff --git a/apps/server/src/settings/store.ts b/apps/server/src/settings/store.ts new file mode 100644 index 0000000..e60c2ea --- /dev/null +++ b/apps/server/src/settings/store.ts @@ -0,0 +1,79 @@ +import { describeIssues, issuesOf, validate } from '../body.ts' +import type { Db } from '../db/index.ts' +import type { Logger } from '../logger.ts' +import { type SettingKey, type Settings, settingSchemas } from './schemas.ts' + +export class StoredSettingError extends Error { + constructor(key: string, details: string) { + super(`The stored "${key}" setting is invalid: ${details}`) + this.name = 'StoredSettingError' + } +} + +type Listener = (value: Settings[K]) => void + +/** Admin settings, held in memory and written through to the database. */ +export class SettingsStore { + private readonly values: Partial + private readonly listeners = new Map>>() + private readonly db: Db + private readonly logger: Logger + + constructor(db: Db, logger: Logger, values: Partial = {}) { + this.db = db + this.logger = logger + this.values = values + } + + /** Read and validate every stored setting. A stored value that fails its schema stops startup. */ + static async load(db: Db, logger: Logger): Promise { + const rows = await db.selectFrom('app_setting').select(['key', 'value_json']).execute() + const values: Record = {} + for (const row of rows) { + const schema = settingSchemas[row.key as SettingKey] + if (!schema) { + logger.warn({ key: row.key }, 'ignoring a stored setting no schema describes') + continue + } + const result = schema.safeParse(JSON.parse(row.value_json)) + if (!result.success) + throw new StoredSettingError(row.key, describeIssues(issuesOf(result.error))) + values[row.key] = result.data + } + return new SettingsStore(db, logger, values as Partial) + } + + get(key: K): Settings[K] { + return (this.values[key] ?? settingSchemas[key].parse({})) as Settings[K] + } + + /** Validate, store, and announce a setting. Throws InvalidBody when it fails its schema. */ + async set(key: K, input: unknown, adminDid: string): Promise { + const value = validate(settingSchemas[key], input) as Settings[K] + const row = { + value_json: JSON.stringify(value), + updated_at: new Date().toISOString(), + updated_by: adminDid, + } + await this.db + .insertInto('app_setting') + .values({ key, ...row }) + .onConflict((oc) => oc.column('key').doUpdateSet(row)) + .execute() + this.values[key] = value + this.logger.info({ key, admin: adminDid }, 'admin setting changed') + for (const listener of this.listeners.get(key) ?? []) (listener as Listener)(value) + return value + } + + /** Call the listener after each change to the setting. */ + subscribe(key: K, listener: Listener): () => void { + let set = this.listeners.get(key) + if (!set) { + set = new Set() + this.listeners.set(key, set) + } + set.add(listener as Listener) + return () => set.delete(listener as Listener) + } +} diff --git a/apps/server/src/storage/routes.ts b/apps/server/src/storage/routes.ts index 6bf69b7..0f8dbee 100644 --- a/apps/server/src/storage/routes.ts +++ b/apps/server/src/storage/routes.ts @@ -9,9 +9,9 @@ import type { AppEnv } from '../env.ts' import type { Logger } from '../logger.ts' import type { HookRunner } from '../plugins/hooks.ts' import { jsonObject } from '../schemas.ts' +import type { Settings } from '../settings/schemas.ts' import type { SyncEngine } from '../sync/engine.ts' import type { SyncEventBus, SyncEvents } from '../sync/events.ts' -import type { ResolvedSyncConfig } from '../sync/scheduler.ts' import type { ChatServices } from './services.ts' export type StorageRoutesDeps = { @@ -19,8 +19,10 @@ export type StorageRoutesDeps = { services: ChatServices events: SyncEventBus engine?: SyncEngine - hooks?: HookRunner - syncConfig: ResolvedSyncConfig + /** The current plugin runtime's hooks. */ + hooks?: () => HookRunner + /** The current sync settings. */ + sync: () => Settings['sync'] logger: Logger } @@ -59,11 +61,9 @@ export function storageRoutes(deps: StorageRoutesDeps) { systemPrompt: body.systemPrompt, tags: body.tags, }) - await deps.hooks?.action( - 'conversation:created', - { user: chats.did, conversation: created.uri }, - deps.logger, - ) + await deps + .hooks?.() + .action('conversation:created', { user: chats.did, conversation: created.uri }, deps.logger) return c.json(created, 201) }) .get('/conversations/keys', async (c) => c.json({ keys: await chatsFor(c).listKeys() })) @@ -95,11 +95,13 @@ export function storageRoutes(deps: StorageRoutesDeps) { const chats = chatsFor(c) const skey = c.req.param('skey') await chats.deleteConversation(skey) - await deps.hooks?.action( - 'conversation:deleted', - { user: chats.did, conversation: chats.conversationUri(skey) }, - deps.logger, - ) + await deps + .hooks?.() + .action( + 'conversation:deleted', + { user: chats.did, conversation: chats.conversationUri(skey) }, + deps.logger, + ) return c.json({ ok: true }) }) .post('/conversations/:skey/sync', async (c) => { @@ -117,13 +119,13 @@ export function storageRoutes(deps: StorageRoutesDeps) { const { account } = signedInUser(c) return c.json({ backgroundSync: account.backgroundSync, - allowUserOptOut: deps.syncConfig.allowUserOptOut, + allowUserOptOut: deps.sync().allowUserOptOut, }) }) .put('/account', async (c) => { const body = await jsonBody(c, accountSettings) if (body.backgroundSync !== undefined) { - if (!deps.syncConfig.allowUserOptOut) { + if (!deps.sync().allowUserOptOut) { return c.json( { error: 'Forbidden', message: 'This app does not allow turning off background sync' }, 403, diff --git a/apps/server/src/sync/engine.ts b/apps/server/src/sync/engine.ts index 7d986f0..fdbb446 100644 --- a/apps/server/src/sync/engine.ts +++ b/apps/server/src/sync/engine.ts @@ -25,7 +25,10 @@ export type SyncEngineDeps = { resolveSigningKey: (did: string) => Promise publicUrl: string logger: Logger - backfillWindowMs: number + /** How old a synced message asking for a reply can be, read on each use. */ + backfillWindowMs: () => number + /** Whether the server may act for the user. Users without access are never synced. */ + hasAccess: (did: string) => Promise now?: () => number } @@ -48,6 +51,10 @@ export class SyncEngine { private async context(did: string): Promise { const account = await getAccount(this.deps.db, did) if (account?.storageMode !== 'space') return null + if (!(await this.deps.hasAccess(did))) { + this.deps.logger.debug({ did }, 'not syncing a user without access') + return null + } const chats = this.deps.services.forAccount(account) return { account, chats, store: chats.store } } @@ -142,7 +149,7 @@ export class SyncEngine { const space = ctx.chats.settingsUri const head = await ctx.store.headRev(space) const { conversations } = await ctx.chats.listConversations() - const cutoff = this.now() - this.deps.backfillWindowMs + const cutoff = this.now() - this.deps.backfillWindowMs() for (const conversation of conversations) { if (Date.parse(conversation.updatedAt) >= cutoff) await this.runConversationSync(ctx, conversation.skey, false) diff --git a/apps/server/src/sync/scheduler.ts b/apps/server/src/sync/scheduler.ts index 41e6f08..f3949ed 100644 --- a/apps/server/src/sync/scheduler.ts +++ b/apps/server/src/sync/scheduler.ts @@ -1,52 +1,43 @@ -import type { SyncConfig } from '../config.ts' import type { Db } from '../db/index.ts' import type { Logger } from '../logger.ts' +import type { Settings } from '../settings/schemas.ts' import type { SyncEngine } from './engine.ts' -export type ResolvedSyncConfig = { - safetyNet: { enabled: boolean; intervalMinutes: number; activeWithinHours: number } - discovery: { intervalMinutes: number; activeWithinDays: number } - allowUserOptOut: boolean -} +export type SyncSettings = Settings['sync'] -export function resolveSyncConfig(config: SyncConfig = {}): ResolvedSyncConfig { - return { - safetyNet: { - enabled: config.safetyNet?.enabled ?? true, - intervalMinutes: config.safetyNet?.intervalMinutes ?? 15, - activeWithinHours: config.safetyNet?.activeWithinHours ?? 24, - }, - discovery: { - intervalMinutes: config.discovery?.intervalMinutes ?? 60, - activeWithinDays: config.discovery?.activeWithinDays ?? 30, - }, - allowUserOptOut: config.allowUserOptOut ?? true, - } -} - -/** Spaces users active since the cutoff who have not turned off background sync. */ +/** Spaces users with access, active since the cutoff, who have not turned off background sync. */ export async function eligibleUsers( db: Db, - config: ResolvedSyncConfig, + config: SyncSettings, activeSince: Date, + hasAccess: (did: string) => Promise, ): Promise { let query = db .selectFrom('account') .select('did') .where('storage_mode', '=', 'space') + .where('viewer_only', '=', 0) .where('last_active_at', '>=', activeSince.toISOString()) if (config.allowUserOptOut) query = query.where('background_sync', '=', 1) - return (await query.execute()).map((row) => row.did) + const dids = (await query.execute()).map((row) => row.did) + const allowed = await Promise.all(dids.map(hasAccess)) + return dids.filter((_, index) => allowed[index]) } -/** Timers for registration renewal, the safety net, and discovery. */ -export function startSyncScheduler(deps: { +export type SchedulerDeps = { engine: SyncEngine db: Db - config: ResolvedSyncConfig + /** The current sync settings. */ + sync: () => SyncSettings + /** Call the listener whenever the sync settings change. */ + onSyncChange: (listener: () => void) => () => void + hasAccess: (did: string) => Promise logger: Logger -}) { - const { engine, db, config, logger } = deps +} + +/** Timers for registration renewal, the safety net, and discovery, restarted when the sync settings change. */ +export function startSyncScheduler(deps: SchedulerDeps) { + const { engine, db, logger } = deps const run = (name: string, work: () => Promise) => () => { work().catch((err) => logger.error({ err, job: name }, 'sync job failed')) } @@ -55,33 +46,42 @@ export function startSyncScheduler(deps: { await fn(did).catch((err) => logger.warn({ err, did }, 'sync job failed for user')) } } - const timers = [ - setInterval( - run('renew', () => engine.renewRegistrations()), - 5 * 60_000, - ), - ] - if (config.safetyNet.enabled) { + const users = (since: Date) => eligibleUsers(db, deps.sync(), since, deps.hasAccess) + const renew = setInterval( + run('renew', () => engine.renewRegistrations()), + 5 * 60_000, + ) + let timers: NodeJS.Timeout[] = [] + const schedule = () => { + for (const timer of timers) clearInterval(timer) + const config = deps.sync() + timers = [] + if (config.safetyNet.enabled) { + timers.push( + setInterval( + run('safety-net', async () => { + const since = new Date(Date.now() - config.safetyNet.activeWithinHours * 3_600_000) + await forEach(await users(since), (did) => engine.safetyNet(did)) + }), + config.safetyNet.intervalMinutes * 60_000, + ), + ) + } timers.push( setInterval( - run('safety-net', async () => { - const since = new Date(Date.now() - config.safetyNet.activeWithinHours * 3_600_000) - await forEach(await eligibleUsers(db, config, since), (did) => engine.safetyNet(did)) + run('discovery', async () => { + const since = new Date(Date.now() - config.discovery.activeWithinDays * 86_400_000) + await forEach(await users(since), (did) => engine.discover(did)) }), - config.safetyNet.intervalMinutes * 60_000, + config.discovery.intervalMinutes * 60_000, ), ) } - timers.push( - setInterval( - run('discovery', async () => { - const since = new Date(Date.now() - config.discovery.activeWithinDays * 86_400_000) - await forEach(await eligibleUsers(db, config, since), (did) => engine.discover(did)) - }), - config.discovery.intervalMinutes * 60_000, - ), - ) + schedule() + const unsubscribe = deps.onSyncChange(schedule) return () => { + unsubscribe() + clearInterval(renew) for (const timer of timers) clearInterval(timer) } } diff --git a/apps/server/src/turns/runner.ts b/apps/server/src/turns/runner.ts index 66387be..d4cf313 100644 --- a/apps/server/src/turns/runner.ts +++ b/apps/server/src/turns/runner.ts @@ -11,8 +11,8 @@ import { type Account, getAccount } from '../auth/accounts.ts' import type { Db } from '../db/index.ts' import type { Logger } from '../logger.ts' import type { PluginHost } from '../plugins/host.ts' +import type { PluginRuntime, RuntimeLease } from '../plugins/runtime.ts' import { isToolEnabled, readToolChoices } from '../plugins/user-tools.ts' -import type { ModelCatalog } from '../providers/catalog.ts' import { mapEffort } from '../providers/effort.ts' import { safeErrorMessage } from '../safe-error.ts' import type { ChatService } from '../storage/chat-service.ts' @@ -39,21 +39,28 @@ export type TurnBlobs = { put(account: Account, bytes: Uint8Array, mimeType: string): Promise } +export type TurnSettings = { + ratePerMinute: number + maxSteps: number + timeoutMs: number + backfillWindowMs: number + systemPrompt: string +} + export type TurnRunnerDeps = { db: Db services: ChatServices - catalog: ModelCatalog - host?: PluginHost + /** Hold the current plugin runtime for the length of a turn. */ + plugins: { acquire(): RuntimeLease } hub: StreamHub blobs: TurnBlobs - config: { - ratePerMinute: number - maxSteps: number - timeoutMs: number - backfillWindowMs: number - systemPrompt: string - } - appName: string + /** The current turn settings, read when each turn starts. */ + settings: () => TurnSettings + appName: () => string + /** Whether the server may act for the user, checked before starting a turn. */ + hasAccess: (did: string) => Promise + /** The user's roles, given to tools when a turn starts. */ + rolesOf: (did: string) => Promise /** Fetch function passed to tools for retrieving URLs. */ toolFetch: typeof fetch logger: Logger @@ -168,6 +175,10 @@ export class TurnRunner { /** Start the turn a user message asks for, if it still needs one. */ async start(did: string, skey: string, userRkey: string): Promise { + if (!(await this.deps.hasAccess(did))) { + this.deps.logger.info({ did, skey }, 'skipping a turn for a user without access') + return { status: 'skipped' } + } const loaded = await this.load(did, skey) const userMessage = loaded.messages.get(userRkey) const record = userMessage?.record as unknown as JsonRecord | undefined @@ -193,7 +204,7 @@ export class TurnRunner { .execute() const starts = this.recentStarts(did) - if (starts.length >= this.deps.config.ratePerMinute) { + if (starts.length >= this.deps.settings().ratePerMinute) { const queue = this.queued.get(did) ?? [] if (!queue.some((turn) => turn.skey === skey && turn.userRkey === userRkey)) queue.push({ skey, userRkey, replyRkey, attempt }) @@ -203,12 +214,37 @@ export class TurnRunner { return { status: 'queued', replyRkey } } + const lease = this.deps.plugins.acquire() + let handedOff = false + try { + const result = await this.claim(loaded, skey, userRkey, generation, attempt, starts, lease) + handedOff = result.status === 'claimed' + return result + } finally { + if (!handedOff) lease.release() + } + } + + /** Write the placeholder reply and start generating it, on the leased runtime. */ + private async claim( + loaded: Loaded, + skey: string, + userRkey: string, + generation: { model?: ModelRef; effort?: string; tools?: string[] }, + attempt: number, + starts: number[], + lease: RuntimeLease, + ): Promise { + const did = loaded.account.did + const replyRkey = replyKey(userRkey, attempt) + const conversationUri = loaded.chats.conversationUri(skey) + const userMessage = loaded.messages.get(userRkey) as BranchMessage const branch = branchTo(loaded.messages, userRkey) const model = generation.model ?? lastReplyModel(branch) ?? (loaded.preferences?.defaultModel as ModelRef | undefined) ?? - this.deps.catalog.defaultModel() + (await lease.runtime.catalog.defaultModel()) const effort = generation.effort ?? (loaded.preferences?.defaultEffort as string | undefined) const placeholder: JsonRecord = { $type: nsid.message, @@ -244,9 +280,10 @@ export class TurnRunner { await this.clearRequest(conversationUri, userRkey, attempt) const work = this.generate( + lease.runtime, loaded, skey, - userMessage as BranchMessage, + userMessage, replyRkey, placeholder, model, @@ -256,7 +293,10 @@ export class TurnRunner { .catch((err) => this.deps.logger.error({ err, did, skey, replyRkey }, 'turn failed outside generation'), ) - .finally(() => this.background.delete(work)) + .finally(() => { + lease.release() + this.background.delete(work) + }) this.background.add(work) return { status: 'claimed', replyRkey } } @@ -289,26 +329,26 @@ export class TurnRunner { } /** The tools switched on for the user, for messages that name none. */ - private async enabledTools(did: string): Promise { - const registered = this.deps.host?.tools.list() ?? [] + private async enabledTools(host: PluginHost, did: string): Promise { + const registered = host.tools.list() if (!registered.length) return [] const choices = await readToolChoices(this.deps.db, did) return registered.filter((item) => isToolEnabled(item, choices)).map((item) => item.name) } /** Whether a tool's output is wrapped before the model sees it. Unknown tools count as untrusted. */ - private isUntrusted(name: string): boolean { - const registered = this.deps.host?.tools.get(name) + private isUntrusted(host: PluginHost, name: string): boolean { + const registered = host.tools.get(name) return !registered || registered.untrusted === true } private async tools( + host: PluginHost, requested: string[], - base: { user: string; conversation: string; signal: AbortSignal }, + base: { user: string; conversation: string; roles: string[]; signal: AbortSignal }, accumulator: PartAccumulator, ) { if (!requested.length) return undefined - const host = this.deps.host const set: ToolSet = {} // Each tool gets its own context, so its turn cache is private and lasts only this turn. const contextFor = (): ToolContext => ({ @@ -323,7 +363,7 @@ export class TurnRunner { value: wrapUntrusted(encode(output)), }), } - for (const registered of host?.tools.list() ?? []) { + for (const registered of host.tools.list()) { if (!requested.includes(registered.name)) continue const context = contextFor() set[registered.name] = tool({ @@ -333,7 +373,7 @@ export class TurnRunner { ...(registered.untrusted ? wrapped : {}), } as never) } - for (const source of host?.toolSources.list() ?? []) { + for (const source of host.toolSources.list()) { for (const definition of await source.list(base.user)) { const name = `${source.id}_${definition.name}` if (!requested.includes(name)) continue @@ -376,6 +416,7 @@ export class TurnRunner { } private async generate( + runtime: PluginRuntime, loaded: Loaded, skey: string, userMessage: BranchMessage, @@ -389,12 +430,11 @@ export class TurnRunner { const did = account.did const conversationUri = chats.conversationUri(skey) const key = this.streamKey(did, skey, replyRkey) + const { host, catalog } = runtime + const settings = this.deps.settings() const controller = new AbortController() this.running.set(key, controller) - const timeout = setTimeout( - () => controller.abort(new Error('timeout')), - this.deps.config.timeoutMs, - ) + const timeout = setTimeout(() => controller.abort(new Error('timeout')), settings.timeoutMs) const accumulator = new PartAccumulator((event) => this.deps.hub.publish(key, event)) const context: TurnContext = { user: did, @@ -414,21 +454,27 @@ export class TurnRunner { try { if (!model) throw new Error('No model selected. Choose a model or set a default in your preferences.') - const resolved = await this.deps.catalog.resolve(did, model) - const hooks = this.deps.host?.hooks + const resolved = await catalog.resolve(did, model) const branch = branchTo(loaded.messages, userMessage.rkey) const requested = generation.tools if (requested?.length && !resolved.capabilities.tools) throw new TurnInputError('This model cannot use tools. Choose a model that can.') - const names = requested ?? (resolved.capabilities.tools ? await this.enabledTools(did) : []) + const names = + requested ?? (resolved.capabilities.tools ? await this.enabledTools(host, did) : []) const tools = await this.tools( + host, names, - { user: did, conversation: conversationUri, signal: controller.signal }, + { + user: did, + conversation: conversationUri, + roles: await this.deps.rolesOf(did), + signal: controller.signal, + }, accumulator, ) context.tools = Object.keys(tools ?? {}) - const base = fillBasePrompt(this.deps.config.systemPrompt, { - appName: this.deps.appName, + const base = fillBasePrompt(settings.systemPrompt, { + appName: this.deps.appName(), timeZone: timeZoneOf(loaded.preferences, this.deps.logger), now: new Date(this.now()), }) @@ -436,19 +482,19 @@ export class TurnRunner { instructions: buildInstructions(base, loaded.preferences, loaded.info), messages: branch, } - const prompt = hooks ? await hooks.filter('messages:beforeModel', initial, context) : initial + const prompt = await host.hooks.filter('messages:beforeModel', initial, context) const messages = await toModelMessages(prompt.messages, { provider: resolved.provider, capabilities: resolved.capabilities, readBlob: this.deps.blobs.reader(account, skey), - isUntrusted: (name) => this.isUntrusted(name), + isUntrusted: (name) => this.isUntrusted(host, name), }) const result = streamText({ model: resolved.model, instructions: prompt.instructions || undefined, messages, tools, - stopWhen: isStepCount(this.deps.config.maxSteps), + stopWhen: isStepCount(settings.maxSteps), reasoning: resolved.capabilities.reasoning ? mapEffort(effort, this.deps.logger) : undefined, @@ -477,9 +523,9 @@ export class TurnRunner { } let content: JsonRecord = { $type: defs('plainContent'), parts: accumulator.parts } - if ((status === 'complete' || status === 'cancelled') && this.deps.host) { + if (status === 'complete' || status === 'cancelled') { try { - content = (await this.deps.host.hooks.filter( + content = (await host.hooks.filter( 'message:afterModel', content as never, context, @@ -525,13 +571,11 @@ export class TurnRunner { .where('conversation_uri', '=', conversationUri) .where('reply_rkey', '=', replyRkey) .execute() - if (this.deps.host) { - await this.deps.host.hooks.action( - 'turn:after', - { ...context, reply: { rkey: replyRkey, record: final as never } }, - this.deps.logger, - ) - } + await host.hooks.action( + 'turn:after', + { ...context, reply: { rkey: replyRkey, record: final as never } }, + this.deps.logger, + ) } /** Reply with an error to a user message that failed validation. */ @@ -562,7 +606,7 @@ export class TurnRunner { const record = event.record if (record.role !== 'user' || !record.generation) return const recent = - Date.parse(String(record.createdAt)) >= this.now() - this.deps.config.backfillWindowMs + Date.parse(String(record.createdAt)) >= this.now() - this.deps.settings().backfillWindowMs if (!event.live && !recent) return this.start(event.did, event.skey, event.rkey).catch((err) => this.deps.logger.error( @@ -607,7 +651,7 @@ export class TurnRunner { this.deps.logger.warn({ err, claim }, 'could not mark an interrupted reply') } } - const cutoff = new Date(this.now() - this.deps.config.backfillWindowMs).toISOString() + const cutoff = new Date(this.now() - this.deps.settings().backfillWindowMs).toISOString() await this.deps.db.deleteFrom('turn_request').where('requested_at', '<', cutoff).execute() const requests = await this.deps.db.selectFrom('turn_request').selectAll().execute() for (const request of requests) { diff --git a/apps/server/tests/admin/plugin-routes.test.ts b/apps/server/tests/admin/plugin-routes.test.ts new file mode 100644 index 0000000..449b2ad --- /dev/null +++ b/apps/server/tests/admin/plugin-routes.test.ts @@ -0,0 +1,337 @@ +import { definePlugin, type ModelProvider } from '@scn-chat/plugin-api' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { adminHarness } from '../helpers/admin.ts' +import { installedFrom } from '../helpers/plugins.ts' + +const caps = { vision: true, reasoning: false, tools: true } + +/** A provider plugin whose provider ID and admin key come from its options. */ +function fixtures() { + const closed: string[] = [] + const optionsSchema = z + .object({ + id: z + .string() + .regex(/^[a-z]+$/) + .default('fake'), + apiKey: z.string().min(1).optional().meta({ secret: true }), + crash: z.boolean().default(false), + }) + .strict() + const installed = installedFrom({ + 'provider-plugin': { + multiple: true, + optionsSchema, + factory: (options: z.infer) => + definePlugin({ + id: `prov-${options.id}`, + name: `Provider ${options.id}`, + apiVersion: 1, + setup(ctx) { + if (options.crash) throw new Error('setup exploded') + ctx.providers.register({ + id: options.id, + name: options.id, + hasAdminKey: Boolean(options.apiKey), + userKeys: true, + replay: 'drop', + createModel: () => { + throw new Error('not in these tests') + }, + listModels: async ({ apiKey }) => [ + { + id: `listed-with-${apiKey ?? options.apiKey}`, + name: 'Listed', + capabilities: caps, + }, + ], + } as ModelProvider) + ctx.onClose(() => void closed.push(options.id)) + }, + }), + }, + 'plain-plugin': { + factory: () => definePlugin({ id: 'plain', name: 'Plain', apiVersion: 1, setup: () => {} }), + }, + }) + return { installed, closed } +} + +async function setup() { + const { installed, closed } = fixtures() + const h = await adminHarness({ installed }) + const instances = async () => + (await h.call('GET', '/admin/plugins')).body.instances as { + id: string + package: string + status: string + enabled: boolean + options: Record + secretsSet: string[] + pluginId: string | null + error: string | null + }[] + const add = async (options: Record, pkg = 'provider-plugin') => + h.call('POST', '/admin/plugins', { package: pkg, options }) + const model = (overrides: Record = {}) => ({ + provider: 'fake', + id: 'big', + name: 'Big', + capabilities: caps, + roles: ['user'], + ...overrides, + }) + return { ...h, closed, instances, add, model } +} + +describe('installed plugins', () => { + it('lists each installed package with its form and secret fields', async () => { + const h = await setup() + const { plugins } = (await h.call('GET', '/admin/plugins/installed')).body as { + plugins: { package: string; secretFields: string[] }[] + } + expect(plugins.map((p) => p.package)).toEqual(['provider-plugin', 'plain-plugin']) + expect(plugins.map((p) => (p as { multiple?: boolean }).multiple)).toEqual([true, false]) + expect(plugins[0]?.secretFields).toEqual(['apiKey']) + }) +}) + +describe('plugin instances', () => { + it('adds an instance and loads it without a restart', async () => { + const h = await setup() + expect((await h.add({ apiKey: 'sk-admin' })).status).toBe(201) + expect(await h.instances()).toEqual([ + expect.objectContaining({ status: 'loaded', pluginId: 'prov-fake', secretsSet: ['apiKey'] }), + ]) + expect(h.holder.current().host.providers.get('fake')?.hasAdminKey).toBe(true) + }) + + it('refuses options that fail the schema, pointing at the field', async () => { + const h = await setup() + const res = await h.add({ id: 'Not Valid' }) + expect(res.status).toBe(400) + expect(res.body.issues).toEqual([expect.objectContaining({ path: ['id'] })]) + expect(await h.instances()).toEqual([]) + }) + + it('refuses a package that is not installed', async () => { + const h = await setup() + expect((await h.add({}, 'nope-plugin')).status).toBe(400) + }) + + it('stores secret options encrypted and never returns them', async () => { + const h = await setup() + await h.add({ apiKey: 'sk-very-secret' }) + const [instance] = await h.instances() + expect(JSON.stringify(instance)).not.toContain('sk-very-secret') + const row = await h.db.selectFrom('plugin_instance').selectAll().executeTakeFirstOrThrow() + expect(row.options_json).not.toContain('sk-very-secret') + expect(row.secrets_encrypted).not.toContain('sk-very-secret') + }) + + it('keeps a stored secret when the form leaves it blank, and removes it on clear', async () => { + const h = await setup() + await h.add({ apiKey: 'sk-admin' }) + const [instance] = await h.instances() + const id = instance?.id as string + await h.call('PUT', `/admin/plugins/${id}`, { options: { id: 'fake', apiKey: '' } }) + expect((await h.instances())[0]?.secretsSet).toEqual(['apiKey']) + expect(h.holder.current().host.providers.get('fake')?.hasAdminKey).toBe(true) + await h.call('PUT', `/admin/plugins/${id}`, { clearSecrets: ['apiKey'] }) + expect((await h.instances())[0]?.secretsSet).toEqual([]) + expect(h.holder.current().host.providers.get('fake')?.hasAdminKey).toBe(false) + }) + + it('refuses an edit that would rename the plugin', async () => { + const h = await setup() + await h.add({}) + const id = (await h.instances())[0]?.id + const res = await h.call('PUT', `/admin/plugins/${id}`, { options: { id: 'other' } }) + expect(res.status).toBe(400) + expect((await h.instances())[0]?.options).toEqual({}) + }) + + it('refuses a change whose setup throws, and leaves the running plugins alone', async () => { + const h = await setup() + await h.add({}) + const before = h.holder.current() + const id = (await h.instances())[0]?.id + const res = await h.call('PUT', `/admin/plugins/${id}`, { options: { crash: true } }) + expect(res.status).toBe(400) + expect(res.body.message).toMatch(/setup exploded/) + expect(h.holder.current()).toBe(before) + expect((await h.instances())[0]?.options).toEqual({}) + }) + + it('adds a package once unless it allows more than one instance', async () => { + const h = await setup() + expect((await h.add({}, 'plain-plugin')).status).toBe(201) + expect((await h.add({}, 'plain-plugin')).status).toBe(400) + expect((await h.add({ id: 'one' })).status).toBe(201) + expect((await h.add({ id: 'two' })).status).toBe(201) + }) + + it('refuses a second instance with the same plugin or provider ID', async () => { + const h = await setup() + await h.add({}) + const res = await h.add({}) + expect(res.status).toBe(400) + expect(res.body.message).toMatch(/prov-fake/) + expect(await h.instances()).toHaveLength(1) + }) + + it('disables an instance without losing its options, and enables it again', async () => { + const h = await setup() + await h.add({ id: 'kept' }) + const id = (await h.instances())[0]?.id + await h.call('PUT', `/admin/plugins/${id}`, { enabled: false }) + expect((await h.instances())[0]).toMatchObject({ status: 'disabled', options: { id: 'kept' } }) + expect(h.holder.current().host.providers.get('kept')).toBeUndefined() + await h.call('PUT', `/admin/plugins/${id}`, { enabled: true }) + expect(h.holder.current().host.providers.get('kept')).toBeDefined() + }) + + it('reorders instances, which changes the load order', async () => { + const h = await setup() + await h.add({ id: 'first' }) + await h.add({}, 'plain-plugin') + const ids = (await h.instances()).map((i) => i.id) + expect((await h.call('PUT', '/admin/plugins/order', { ids: ids.slice(0, 1) })).status).toBe(400) + await h.call('PUT', '/admin/plugins/order', { ids: [...ids].reverse() }) + expect((await h.instances()).map((i) => i.package)).toEqual(['plain-plugin', 'provider-plugin']) + expect(h.holder.current().host.plugins.map((p) => p.id)).toEqual(['plain', 'prov-first']) + }) + + it('removes an instance and keeps its users settings', async () => { + const h = await setup() + await h.add({}) + await h.db + .insertInto('plugin_user_settings') + .values({ + did: 'did:plc:alice', + plugin_id: 'prov-fake', + values_json: '{}', + secrets_encrypted: null, + updated_at: new Date().toISOString(), + }) + .execute() + const id = (await h.instances())[0]?.id + expect((await h.call('DELETE', `/admin/plugins/${id}`)).status).toBe(200) + expect(await h.instances()).toEqual([]) + expect(await h.db.selectFrom('plugin_user_settings').selectAll().execute()).toHaveLength(1) + expect((await h.call('DELETE', `/admin/plugins/${id}`)).status).toBe(404) + }) + + it('closes a replaced runtime', async () => { + const h = await setup() + await h.add({ id: 'old' }) + const id = (await h.instances())[0]?.id + await h.call('PUT', `/admin/plugins/${id}`, { enabled: false }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(h.closed).toEqual(['old']) + }) +}) + +describe('listing a provider plugin models', () => { + it('uses the form options, with blank secrets from the stored instance, and closes the scratch plugin', async () => { + const h = await setup() + await h.add({ apiKey: 'sk-stored' }) + const id = (await h.instances())[0]?.id + const stored = await h.call('POST', '/admin/plugins/list-models', { + package: 'provider-plugin', + instanceId: id, + options: { id: 'fake', apiKey: '' }, + }) + expect(stored.body.models).toEqual([ + expect.objectContaining({ provider: 'fake', id: 'listed-with-sk-stored' }), + ]) + const typed = await h.call('POST', '/admin/plugins/list-models', { + package: 'provider-plugin', + options: { id: 'draft', apiKey: 'sk-typed' }, + }) + expect(typed.body.models).toEqual([ + expect.objectContaining({ provider: 'draft', id: 'listed-with-sk-typed' }), + ]) + expect(h.closed).toContain('draft') + }) +}) + +describe('admin models', () => { + it('refuses a model with an unknown provider, a keyless provider, an undefined role, or no roles', async () => { + const h = await setup() + await h.add({}, 'provider-plugin') + for (const bad of [ + h.model({ provider: 'ghost' }), + h.model(), + h.model({ roles: ['vip'] }), + h.model({ roles: [] }), + ]) { + expect((await h.call('POST', '/admin/models', bad)).status).toBe(400) + } + }) + + it('adds, changes, reorders, and removes models, with one default at most', async () => { + const h = await setup() + await h.add({ apiKey: 'k' }) + expect((await h.call('POST', '/admin/models', h.model({ default: true }))).status).toBe(201) + expect((await h.call('POST', '/admin/models', h.model())).status).toBe(400) + await h.call('POST', '/admin/models', h.model({ id: 'small', default: true })) + const list = async () => + ( + (await h.call('GET', '/admin/models')).body.models as { id: string; default: boolean }[] + ).map((m) => `${m.id}${m.default ? '*' : ''}`) + expect(await list()).toEqual(['big', 'small*']) + await h.call('PUT', '/admin/models', h.model({ name: 'Bigger', default: true })) + expect(await list()).toEqual(['big*', 'small']) + await h.call('PUT', '/admin/models/order', { + models: [ + { provider: 'fake', id: 'small' }, + { provider: 'fake', id: 'big' }, + ], + }) + expect(await list()).toEqual(['small', 'big*']) + expect( + (await h.call('DELETE', '/admin/models', { provider: 'fake', id: 'small' })).status, + ).toBe(200) + expect(await list()).toEqual(['big*']) + expect((await h.call('PUT', '/admin/models', h.model({ id: 'gone' }))).status).toBe(404) + }) + + it('makes a new model available to users on the next request', async () => { + const h = await setup() + await h.add({ apiKey: 'k' }) + await h.call('POST', '/admin/models', h.model()) + expect( + ((await h.call('GET', '/models')).body.models as { id: string }[]).map((m) => m.id), + ).toEqual(['big']) + }) + + it('refuses to remove, disable, or clear the key of a provider that models use, naming them', async () => { + const h = await setup() + await h.add({ apiKey: 'k' }) + await h.call('POST', '/admin/models', h.model()) + const id = (await h.instances())[0]?.id + for (const [method, body] of [ + ['DELETE', undefined], + ['PUT', { enabled: false }], + ['PUT', { clearSecrets: ['apiKey'] }], + ] as const) { + const res = await h.call(method, `/admin/plugins/${id}`, body) + expect(res.status).toBe(400) + expect(res.body.message).toMatch(/fake\/big/) + } + }) + + it('flags a model whose provider is not loaded, and hides it from users', async () => { + const h = await setup() + await h.add({ apiKey: 'k' }) + await h.call('POST', '/admin/models', h.model()) + await h.db.updateTable('admin_model').set({ provider: 'ghost' }).execute() + const models = (await h.call('GET', '/admin/models')).body.models as { + warning: string | null + }[] + expect(models[0]?.warning).toMatch(/ghost/) + expect((await h.call('GET', '/models')).body.models).toEqual([]) + }) +}) diff --git a/apps/server/tests/admin/routes.test.ts b/apps/server/tests/admin/routes.test.ts new file mode 100644 index 0000000..94d5f65 --- /dev/null +++ b/apps/server/tests/admin/routes.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi } from 'vitest' +import { recordLogin } from '../../src/auth/accounts.ts' +import { saveAdminModel } from '../../src/providers/admin-models.ts' +import { adminHarness } from '../helpers/admin.ts' +import { TEST_ADMIN } from '../helpers/config.ts' + +const caps = { vision: false, reasoning: false, tools: false } + +describe('admin checks', () => { + it('refuses every admin route to a user who is not an admin', async () => { + const h = await adminHarness() + for (const path of [ + '/admin/users', + '/admin/roles', + '/admin/access', + '/admin/plugins', + '/admin/settings', + ]) { + expect((await h.call('GET', path, undefined, 'did:plc:alice')).status).toBe(403) + } + }) +}) + +describe('users', () => { + it('pages through accounts 50 at a time, most recently active first, with their roles', async () => { + const h = await adminHarness() + const base = { handle: null, pdsUrl: 'https://pds.test', spacesAllowed: true } + for (let i = 0; i < 55; i++) { + await recordLogin( + h.db, + { ...base, did: `did:plc:user${String(i).padStart(2, '0')}` }, + new Date(Date.UTC(2026, 0, 1, 0, i)), + ) + } + const first = await h.call('GET', '/admin/users') + const users = first.body.users as { did: string; roles: unknown[] }[] + expect(users).toHaveLength(50) + expect(users[0]).toMatchObject({ + did: TEST_ADMIN, + roles: [{ role: 'admin', source: 'environment' }], + }) + expect(users[1]?.did).toBe('did:plc:user54') + const second = await h.call('GET', `/admin/users?cursor=${first.body.cursor}`) + expect((second.body.users as unknown[]).length).toBe(6) + expect(second.body.cursor).toBeNull() + }) + + it('filters by the start of a handle or DID', async () => { + const h = await adminHarness() + await recordLogin(h.db, { + did: 'did:plc:bob', + handle: 'bob.test', + pdsUrl: 'https://p', + spacesAllowed: true, + }) + await recordLogin(h.db, { + did: 'did:plc:carol', + handle: 'carol.test', + pdsUrl: 'https://p', + spacesAllowed: true, + }) + const byHandle = await h.call('GET', '/admin/users?q=bo') + expect((byHandle.body.users as { did: string }[]).map((u) => u.did)).toEqual(['did:plc:bob']) + const byDid = await h.call('GET', '/admin/users?q=did:plc:car') + expect((byDid.body.users as { did: string }[]).map((u) => u.did)).toEqual(['did:plc:carol']) + expect(((await h.call('GET', '/admin/users?q=%25')).body.users as unknown[]).length).toBe(0) + }) +}) + +describe('roles', () => { + it('creates a role, adds a member by handle, and lists both', async () => { + const h = await adminHarness() + vi.mocked(h.identity.resolveHandle).mockResolvedValueOnce('did:plc:bob') + expect( + (await h.call('POST', '/admin/roles', { name: 'member', description: 'Members' })).status, + ).toBe(201) + const added = await h.call('POST', '/admin/roles/member/members', { identifier: '@bob.test' }) + expect(added).toMatchObject({ status: 201, body: { did: 'did:plc:bob' } }) + const roles = (await h.call('GET', '/admin/roles')).body + expect(roles.environmentAdmins).toEqual([TEST_ADMIN]) + expect(roles.roles).toContainEqual( + expect.objectContaining({ + name: 'member', + description: 'Members', + members: [expect.objectContaining({ did: 'did:plc:bob', addedBy: TEST_ADMIN })], + }), + ) + }) + + it('refuses a handle that does not resolve, and an invalid DID', async () => { + const h = await adminHarness() + vi.mocked(h.identity.resolveHandle).mockResolvedValueOnce(null) + const handle = await h.call('POST', '/admin/roles/admin/members', { identifier: 'ghost.test' }) + expect(handle.status).toBe(400) + expect(handle.body.message).toMatch(/ghost\.test/) + const did = await h.call('POST', '/admin/roles/admin/members', { identifier: 'did:nope' }) + expect(did.status).toBe(400) + }) + + it('refuses invalid names, duplicates, and the built-in names', async () => { + const h = await adminHarness() + expect((await h.call('POST', '/admin/roles', { name: 'Bad Name' })).status).toBe(400) + expect((await h.call('POST', '/admin/roles', { name: 'user' })).status).toBe(400) + expect((await h.call('POST', '/admin/roles', { name: 'admin' })).status).toBe(400) + expect((await h.call('DELETE', '/admin/roles/admin')).status).toBe(400) + expect((await h.call('DELETE', '/admin/roles/nope')).status).toBe(404) + }) + + it('saves PDS hosts and handle domains, lowercased, and refuses ones that are not hostnames', async () => { + const h = await adminHarness() + await h.call('POST', '/admin/roles', { name: 'member' }) + const bad = await h.call('PATCH', '/admin/roles/member', { pdsHosts: ['https://pds.test/'] }) + expect(bad.status).toBe(400) + expect(bad.body.issues).toEqual([expect.objectContaining({ path: ['pdsHosts', 0] })]) + await h.call('PATCH', '/admin/roles/member', { + pdsHosts: ['PDS.Example.com'], + handleDomains: ['example.com'], + }) + const role = ((await h.call('GET', '/admin/roles')).body.roles as { name: string }[]).find( + (r) => r.name === 'member', + ) + expect(role).toMatchObject({ pdsHosts: ['pds.example.com'], handleDomains: ['example.com'] }) + }) + + it('applies a role change on the next request, without signing in again', async () => { + const h = await adminHarness() + await h.call('POST', '/admin/roles', { name: 'member' }) + await h.signIn('did:plc:alice') + expect((await h.call('GET', '/me', undefined, 'did:plc:alice')).body.roles).toEqual(['user']) + await h.call('POST', '/admin/roles/member/members', { identifier: 'did:plc:alice' }) + expect((await h.call('GET', '/me', undefined, 'did:plc:alice')).body.roles).toEqual([ + 'user', + 'member', + ]) + }) + + it('refuses to delete a role named by access or an admin model, listing what names it', async () => { + const h = await adminHarness() + await h.call('POST', '/admin/roles', { name: 'member' }) + await h.call('PUT', '/admin/access', { registration: 'invite', inviteRoles: ['member'] }) + await saveAdminModel( + h.db, + { + provider: 'fake', + id: 'm', + name: 'M', + capabilities: caps, + roles: ['member'], + default: false, + }, + TEST_ADMIN, + ) + const res = await h.call('DELETE', '/admin/roles/member') + expect(res.status).toBe(400) + expect(res.body.message).toContain('access') + expect(res.body.message).toContain('fake/m') + }) + + it('deletes an unused role with its members', async () => { + const h = await adminHarness() + await h.call('POST', '/admin/roles', { name: 'member' }) + await h.call('POST', '/admin/roles/member/members', { identifier: 'did:plc:alice' }) + expect((await h.call('DELETE', '/admin/roles/member')).status).toBe(200) + const names = ((await h.call('GET', '/admin/roles')).body.roles as { name: string }[]).map( + (r) => r.name, + ) + expect(names).toEqual(['admin']) + }) + + it('removes explicit members, but not admins from the environment', async () => { + const h = await adminHarness() + await h.call('POST', '/admin/roles/admin/members', { identifier: 'did:plc:alice' }) + expect((await h.call('DELETE', '/admin/roles/admin/members/did:plc:alice')).status).toBe(200) + expect((await h.call('DELETE', '/admin/roles/admin/members/did:plc:alice')).status).toBe(404) + expect((await h.call('DELETE', `/admin/roles/admin/members/${TEST_ADMIN}`)).status).toBe(400) + }) +}) + +describe('access', () => { + it('saves the registration mode and invite roles, and refuses roles that do not exist', async () => { + const h = await adminHarness() + const bad = await h.call('PUT', '/admin/access', { + registration: 'invite', + inviteRoles: ['ghost'], + }) + expect(bad.status).toBe(400) + expect(bad.body.issues).toEqual([expect.objectContaining({ path: ['inviteRoles'] })]) + expect((await h.call('PUT', '/admin/access', { registration: 'nope' })).status).toBe(400) + expect( + (await h.call('PUT', '/admin/access', { registration: 'invite', inviteRoles: ['admin'] })) + .body, + ).toEqual({ registration: 'invite', inviteRoles: ['admin'] }) + }) +}) + +describe('added users', () => { + it('adds a person by handle so they can create an account on a closed server', async () => { + const h = await adminHarness() + await h.call('PUT', '/admin/access', { registration: 'closed' }) + vi.mocked(h.identity.resolveHandle).mockResolvedValueOnce('did:plc:bob') + expect(await h.call('POST', '/admin/users', { identifier: 'bob.test' })).toMatchObject({ + status: 201, + body: { did: 'did:plc:bob' }, + }) + expect((await h.call('GET', '/admin/invites')).body.invites).toEqual([ + expect.objectContaining({ did: 'did:plc:bob', addedBy: TEST_ADMIN }), + ]) + expect((await h.signIn('did:plc:bob')).headers.get('location')).toBe('/') + expect((await h.call('GET', '/admin/invites')).body.invites).toEqual([]) + }) + + it('refuses to add someone who already has an account', async () => { + const h = await adminHarness() + await h.signIn('did:plc:alice') + expect((await h.call('POST', '/admin/users', { identifier: 'did:plc:alice' })).status).toBe(400) + expect((await h.call('GET', '/admin/invites')).body.invites).toEqual([]) + }) + + it('removes an added person who has not signed in', async () => { + const h = await adminHarness() + await h.call('POST', '/admin/users', { identifier: 'did:plc:bob' }) + expect((await h.call('DELETE', '/admin/invites/did:plc:bob')).status).toBe(200) + expect((await h.call('DELETE', '/admin/invites/did:plc:bob')).status).toBe(404) + }) +}) + +describe('suspending users', () => { + it('suspends and restores an account, and lists the suspension', async () => { + const h = await adminHarness() + await h.signIn('did:plc:alice') + expect( + (await h.call('POST', '/admin/users/did:plc:alice/suspend', { reason: 'Spam' })).status, + ).toBe(200) + expect((await h.call('GET', '/conversations', undefined, 'did:plc:alice')).status).toBe(403) + const users = (await h.call('GET', '/admin/users')).body.users as { + did: string + suspension: unknown + }[] + expect(users.find((u) => u.did === 'did:plc:alice')?.suspension).toMatchObject({ + by: TEST_ADMIN, + reason: 'Spam', + }) + await h.call('POST', '/admin/users/did:plc:alice/restore') + expect((await h.call('GET', '/conversations', undefined, 'did:plc:alice')).status).not.toBe(403) + }) + + it('refuses to suspend an admin, and answers 404 for an unknown account', async () => { + const h = await adminHarness() + await h.signIn(TEST_ADMIN) + expect((await h.call('POST', `/admin/users/${TEST_ADMIN}/suspend`)).status).toBe(400) + expect((await h.call('POST', '/admin/users/did:plc:nobody/suspend')).status).toBe(404) + }) +}) diff --git a/apps/server/tests/admin/settings-routes.test.ts b/apps/server/tests/admin/settings-routes.test.ts new file mode 100644 index 0000000..bc0c5ea --- /dev/null +++ b/apps/server/tests/admin/settings-routes.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { adminHarness } from '../helpers/admin.ts' + +describe('settings routes', () => { + it('saves a setting, and refuses an invalid one with its issues', async () => { + const h = await adminHarness() + const bad = await h.call('PUT', '/admin/settings/turns', { maxSteps: 0 }) + expect(bad.status).toBe(400) + expect(bad.body.issues).toEqual([expect.objectContaining({ path: ['maxSteps'] })]) + const good = await h.call('PUT', '/admin/settings/general', { appName: 'Renamed' }) + expect(good.body).toEqual({ value: { appName: 'Renamed' } }) + expect(h.settings.get('general').appName).toBe('Renamed') + expect(await (await h.app.request('/oauth-client-metadata.json')).json()).not.toHaveProperty( + 'client_name', + ) + }) + + it('does not edit access, or settings that do not exist, through the forms', async () => { + const h = await adminHarness() + expect((await h.call('PUT', '/admin/settings/access', { registration: 'closed' })).status).toBe( + 404, + ) + expect((await h.call('PUT', '/admin/settings/nope', {})).status).toBe(404) + }) + + it('applies a new session lifetime to new sessions and leaves existing ones alone', async () => { + const h = await adminHarness() + await h.call('PUT', '/admin/settings/sessions', { ttlDays: 2 }) + const res = await h.signIn('did:plc:admin') + expect(res.headers.get('set-cookie')).toMatch(/Max-Age=172800/) + const rows = await h.db.selectFrom('web_session').select(['created_at', 'expires_at']).execute() + const lifetimes = rows.map((r) => Date.parse(r.expires_at) - Date.parse(r.created_at)) + expect(lifetimes.sort()).toEqual([2 * 86_400_000, 30 * 86_400_000]) + }) +}) diff --git a/apps/server/tests/app.test.ts b/apps/server/tests/app.test.ts index 4bf27f7..e15e0a4 100644 --- a/apps/server/tests/app.test.ts +++ b/apps/server/tests/app.test.ts @@ -7,18 +7,23 @@ import pino from 'pino' import { afterAll, describe, expect, it } from 'vitest' import { createApp } from '../src/app.ts' import type { Db } from '../src/db/index.ts' +import { migrateToLatest } from '../src/db/migrate.ts' import type { Database } from '../src/db/schema.ts' +import { SettingsStore } from '../src/settings/store.ts' import { testConfig } from './helpers/config.ts' import { createSqliteDb, dialects } from './helpers/db.ts' import { pdsError } from './helpers/pds-errors.ts' const config = testConfig() const logger = pino({ level: 'silent' }) +const named = (db: Db, appName: string) => new SettingsStore(db, logger, { general: { appName } }) describe.each(dialects)('GET /api/health on $name', ({ create }) => { it('returns 200 with the app name when the database is reachable', async () => { const db = create() - const res = await createApp({ config, db, logger }).request('/api/health') + const res = await createApp({ config, db, logger, settings: named(db, 'Test Chat') }).request( + '/api/health', + ) expect(res.status).toBe(200) expect(await res.json()).toEqual({ status: 'ok', appName: 'Test Chat' }) await db.destroy() @@ -39,40 +44,33 @@ describe('GET /api/health', () => { describe('production static serving', () => { const webDist = mkdtempSync(join(tmpdir(), 'scn-web-')) writeFileSync(join(webDist, 'index.html'), 'index') - writeFileSync(join(webDist, 'app.js'), 'console.log(1)') afterAll(() => rmSync(webDist, { recursive: true, force: true })) const db = createSqliteDb() const app = createApp({ config, db, logger, webDist }) - it('serves built files', async () => { - const res = await app.request('/app.js') - expect(res.status).toBe(200) - expect(await res.text()).toBe('console.log(1)') - }) - - it('falls back to index.html for unknown non-API paths', async () => { - const res = await app.request('/c/some-chat') - expect(res.status).toBe(200) - expect(await res.text()).toContain('index') - }) - - it('fills the app name into index.html, escaped', async () => { + it('serves index.html for app paths, with the app name filled in and escaped', async () => { const dist = mkdtempSync(join(tmpdir(), 'scn-web-')) writeFileSync(join(dist, 'index.html'), '__APP_NAME__') - const named = createApp({ - config: { ...config, appName: 'Chat & ' }, - db, - logger, - webDist: dist, - }) + const app = createApp({ config, db, logger, settings: named(db, 'Chat & '), webDist: dist }) for (const path of ['/', '/index.html', '/c/some-chat']) { - expect(await (await named.request(path)).text()).toBe( - 'Chat & <Co>', - ) + expect(await (await app.request(path)).text()).toBe('Chat & <Co>') } rmSync(dist, { recursive: true, force: true }) }) + it('shows a renamed app without a restart', async () => { + const dist = mkdtempSync(join(tmpdir(), 'scn-web-')) + writeFileSync(join(dist, 'index.html'), '__APP_NAME__') + const migrated = createSqliteDb() + await migrateToLatest(migrated) + const settings = named(migrated, 'Before') + const app = createApp({ config, db: migrated, logger, settings, webDist: dist }) + await settings.set('general', { appName: 'After' }, 'did:plc:admin') + expect(await (await app.request('/')).text()).toBe('After') + expect(await (await app.request('/api/health')).json()).toMatchObject({ appName: 'After' }) + rmSync(dist, { recursive: true, force: true }) + }) + it('returns 404 for unknown API paths', async () => { const res = await app.request('/api/nope') expect(res.status).toBe(404) diff --git a/apps/server/tests/auth/access.test.ts b/apps/server/tests/auth/access.test.ts new file mode 100644 index 0000000..ed37027 --- /dev/null +++ b/apps/server/tests/auth/access.test.ts @@ -0,0 +1,251 @@ +import type { RoleSource } from '@scn-chat/plugin-api' +import pino from 'pino' +import { describe, expect, it, vi } from 'vitest' +import { createApp } from '../../src/app.ts' +import { Access, CannotSuspendAdmin } from '../../src/auth/access.ts' +import { getAccount, recordLogin } from '../../src/auth/accounts.ts' +import { addInvite, isInvited } from '../../src/auth/invites.ts' +import { addMember, createRole } from '../../src/auth/role-store.ts' +import { migrateToLatest } from '../../src/db/migrate.ts' +import type { Settings } from '../../src/settings/schemas.ts' +import { + authDeps, + fakeOAuth, + fakeSession, + LOGIN_STATE, + ORIGIN, + sessionCookie, +} from '../helpers/auth.ts' +import { testConfig } from '../helpers/config.ts' +import { createSqliteDb } from '../helpers/db.ts' +import { testRoles, testSettings } from '../helpers/settings.ts' + +const SHARE = '/s/did:plc:owner/3aaaaaaaaaaaa' +const logger = pino({ level: 'silent' }) + +/** An app whose sign-ins are for whichever DID `as` names. */ +async function setup(values: Partial = {}, sources: RoleSource[] = []) { + const db = createSqliteDb() + await migrateToLatest(db) + const settings = testSettings(db, values) + const roles = testRoles(db, { sources: () => sources }) + const access = new Access({ db, roles, settings, logger }) + const onLogin = vi.fn(async () => {}) + let as = 'did:plc:alice' + const oauth = fakeOAuth({ + callback: vi.fn(async () => ({ session: fakeSession(as, 'atproto'), state: LOGIN_STATE })), + }) + const auth = authDeps(db, { oauth, roles, access, settings, onLogin }) + const app = createApp({ config: testConfig(), db, logger, auth }) + const signIn = (did = 'did:plc:alice', next?: string) => { + as = did + return app.request('/oauth/callback?code=a&state=b', { + headers: { + cookie: `scn_login=${LOGIN_STATE}${next ? `; scn_next=${encodeURIComponent(next)}` : ''}`, + }, + }) + } + const me = async (cookie: string) => + (await (await app.request('/api/me', { headers: { cookie } })).json()) as Record< + string, + unknown + > + const chats = (cookie: string) => app.request('/api/conversations', { headers: { cookie } }) + return { db, app, oauth, onLogin, settings, access, signIn, me, chats } +} + +const errorOf = (res: Response) => + new URL(res.headers.get('location') ?? '', 'http://x').searchParams.get('error') ?? '' + +describe('registration modes', () => { + it('lets anyone create an account on a fresh install', async () => { + const { signIn, onLogin } = await setup() + expect((await signIn()).headers.get('location')).toBe('/') + expect(onLogin).toHaveBeenCalled() + }) + + it('in invite mode lets in holders of an invite role and added people, and turns away anyone else', async () => { + const { db, signIn } = await setup({ + access: { registration: 'invite', inviteRoles: ['member'] }, + }) + await createRole(db, { name: 'member', description: '' }) + await addMember(db, 'member', 'did:plc:alice', 'did:plc:admin') + await addInvite(db, 'did:plc:bob', 'did:plc:admin') + expect((await signIn('did:plc:alice')).headers.get('location')).toBe('/') + expect((await signIn('did:plc:bob')).headers.get('location')).toBe('/') + expect(errorOf(await signIn('did:plc:carol'))).toContain('did:plc:carol') + }) + + it('in invite mode counts roles a role source grants', async () => { + const { db, signIn } = await setup( + { access: { registration: 'invite', inviteRoles: ['member'] } }, + [ + { + id: 'applications', + rolesFor: async ({ did }) => (did === 'did:plc:alice' ? ['member'] : []), + }, + ], + ) + await createRole(db, { name: 'member', description: '' }) + expect((await signIn()).headers.get('location')).toBe('/') + }) + + it('in closed mode lets in only added people and admins', async () => { + const { db, signIn } = await setup({ access: { registration: 'closed', inviteRoles: [] } }) + await addInvite(db, 'did:plc:bob', 'did:plc:admin') + expect((await signIn('did:plc:admin')).headers.get('location')).toBe('/') + expect((await signIn('did:plc:bob')).headers.get('location')).toBe('/') + expect(errorOf(await signIn('did:plc:alice'))).toContain('did:plc:alice') + }) + + it('revokes a turned-away user OAuth session and creates no account', async () => { + const { db, oauth, onLogin, signIn } = await setup({ + access: { registration: 'closed', inviteRoles: [] }, + }) + await signIn() + expect(oauth.revoke).toHaveBeenCalledWith('did:plc:alice') + expect(await getAccount(db, 'did:plc:alice')).toBeUndefined() + expect(onLogin).not.toHaveBeenCalled() + }) + + it('keeps an existing account working when the mode changes', async () => { + const { settings, signIn, chats } = await setup() + const cookie = sessionCookie(await signIn()) + await settings.set('access', { registration: 'closed' }, 'did:plc:admin') + expect((await chats(cookie)).status).not.toBe(403) + expect((await signIn()).headers.get('location')).toBe('/') + }) + + it('deletes the added person row once they create an account', async () => { + const { db, signIn } = await setup({ access: { registration: 'closed', inviteRoles: [] } }) + await addInvite(db, 'did:plc:alice', 'did:plc:admin') + await signIn() + expect(await isInvited(db, 'did:plc:alice')).toBe(false) + }) +}) + +describe('suspension', () => { + it('refuses the chat app to a suspended account without its reason, and keeps its session for shared chats', async () => { + const { access, signIn, chats, me } = await setup() + const cookie = sessionCookie(await signIn()) + await access.suspend('did:plc:alice', 'did:plc:admin', 'Spamming.') + const res = await chats(cookie) + expect(res.status).toBe(403) + const body = (await res.json()) as { error: string; message: string } + expect(body.error).toBe('AccessDenied') + expect(body.message).not.toContain('Spamming') + expect(await me(cookie)).toMatchObject({ access: 'viewer' }) + expect(await access.hasAccess('did:plc:alice')).toBe(false) + }) + + it('turns away a suspended user at the login page, and gives them a viewer session from a share link', async () => { + const { access, signIn, me } = await setup() + await signIn() + await access.suspend('did:plc:alice', 'did:plc:admin', 'Chargeback, see ticket 123') + const turnedAway = await signIn() + expect(errorOf(turnedAway)).not.toBe('') + expect(turnedAway.headers.get('location')).not.toContain('ticket') + const viewer = await signIn('did:plc:alice', SHARE) + expect(viewer.headers.get('location')).toBe(SHARE) + expect(await me(sessionCookie(viewer))).toMatchObject({ access: 'viewer' }) + }) + + it('gives access back on the next request after a restore', async () => { + const { access, signIn, chats } = await setup() + const cookie = sessionCookie(await signIn()) + await access.suspend('did:plc:alice', 'did:plc:admin') + expect(await access.restore('did:plc:alice', 'did:plc:admin')).toBe(true) + expect((await chats(cookie)).status).not.toBe(403) + expect(await access.restore('did:plc:alice', 'did:plc:admin')).toBe(false) + }) + + it('records who suspended an account, and reports whether anything changed', async () => { + const { db, access, signIn } = await setup() + await signIn() + expect(await access.suspend('did:plc:alice', 'plugin:members', 'Revoked')).toBe(true) + expect(await access.suspend('did:plc:alice', 'plugin:members')).toBe(false) + expect(await access.suspend('did:plc:nobody', 'plugin:members')).toBe(false) + expect((await getAccount(db, 'did:plc:alice'))?.suspension).toMatchObject({ + by: 'plugin:members', + reason: 'Revoked', + }) + }) + + it('never suspends an admin', async () => { + const { access, signIn } = await setup() + await signIn('did:plc:admin') + await expect(access.suspend('did:plc:admin', 'plugin:members')).rejects.toBeInstanceOf( + CannotSuspendAdmin, + ) + }) +}) + +describe('viewers', () => { + const closed = { access: { registration: 'closed' as const, inviteRoles: [] } } + + it('signs in someone who may not create an account from a share link as a viewer', async () => { + const { db, oauth, onLogin, signIn, me } = await setup(closed) + const res = await signIn('did:plc:alice', SHARE) + expect(res.headers.get('location')).toBe(SHARE) + expect(oauth.revoke).not.toHaveBeenCalled() + expect(onLogin).not.toHaveBeenCalled() + expect((await getAccount(db, 'did:plc:alice'))?.viewerOnly).toBe(true) + expect(await me(sessionCookie(res))).toMatchObject({ + access: 'viewer', + admin: false, + accessMessage: expect.stringContaining('did:plc:alice'), + }) + }) + + it('keeps a viewer to /api/me, logout, and shared chats', async () => { + const { app, signIn, chats } = await setup(closed) + const cookie = sessionCookie(await signIn('did:plc:alice', SHARE)) + const blocked = await chats(cookie) + expect(blocked.status).toBe(403) + expect(await blocked.json()).toMatchObject({ error: 'AccessDenied' }) + expect((await app.request('/api/me', { headers: { cookie } })).status).toBe(200) + expect((await app.request('/api/shared/did:plc:owner/x', { headers: { cookie } })).status).toBe( + 404, + ) + const logout = await app.request('/api/logout', { + method: 'POST', + headers: { cookie, origin: ORIGIN }, + }) + expect(logout.status).toBe(200) + }) + + it('gives someone who may create an account a full account through a share link', async () => { + const { onLogin, signIn, me } = await setup() + const res = await signIn('did:plc:alice', SHARE) + expect(await me(sessionCookie(res))).toMatchObject({ access: 'full', accessMessage: null }) + expect(onLogin).toHaveBeenCalled() + }) + + it('asks a viewer who may now create an account to sign in again, which sets them up', async () => { + const { db, onLogin, settings, signIn, me, chats } = await setup(closed) + const cookie = sessionCookie(await signIn('did:plc:alice', SHARE)) + await settings.set('access', { registration: 'open' }, 'did:plc:admin') + expect(await me(cookie)).toMatchObject({ access: 'viewer' }) + expect((await chats(cookie)).status).toBe(403) + expect(await me(sessionCookie(await signIn()))).toMatchObject({ access: 'full' }) + expect(onLogin).toHaveBeenCalledOnce() + expect((await getAccount(db, 'did:plc:alice'))?.viewerOnly).toBe(false) + }) +}) + +describe('Access.hasAccess', () => { + it('is true for full accounts, and false for viewers, suspended accounts, and unknown DIDs', async () => { + const db = createSqliteDb() + await migrateToLatest(db) + const access = new Access({ db, roles: testRoles(db), settings: testSettings(db), logger }) + const base = { handle: null, pdsUrl: 'https://pds.test', spacesAllowed: true } + await recordLogin(db, { ...base, did: 'did:plc:full' }) + await recordLogin(db, { ...base, did: 'did:plc:viewer', viewer: true }) + await recordLogin(db, { ...base, did: 'did:plc:gone' }) + await access.suspend('did:plc:gone', 'did:plc:admin') + expect(await access.hasAccess('did:plc:full')).toBe(true) + expect(await access.hasAccess('did:plc:viewer')).toBe(false) + expect(await access.hasAccess('did:plc:gone')).toBe(false) + expect(await access.hasAccess('did:plc:nobody')).toBe(false) + }) +}) diff --git a/apps/server/tests/auth/accounts.test.ts b/apps/server/tests/auth/accounts.test.ts index 369d49e..f7fa04d 100644 --- a/apps/server/tests/auth/accounts.test.ts +++ b/apps/server/tests/auth/accounts.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - getAccount, - recordLogin, - SpacesLostError, - setBackgroundSync, - touchActivity, -} from '../../src/auth/accounts.ts' +import { getAccount, recordLogin, SpacesLostError, touchActivity } from '../../src/auth/accounts.ts' import { migrateToLatest } from '../../src/db/migrate.ts' import { dialects } from '../helpers/db.ts' @@ -18,15 +12,11 @@ describe.each(dialects)('accounts on $name', ({ create }) => { return db } - it('gives a new account space storage when its scope allows spaces', async () => { + it('gives a new account space storage only when its scope allows spaces', async () => { const db = await setup() expect((await recordLogin(db, { ...login, spacesAllowed: true })).storageMode).toBe('space') - await db.destroy() - }) - - it('gives a new account local storage when its scope lacks spaces', async () => { - const db = await setup() - expect((await recordLogin(db, { ...login, spacesAllowed: false })).storageMode).toBe('local') + const bob = { ...login, did: 'did:plc:bob', spacesAllowed: false } + expect((await recordLogin(db, bob)).storageMode).toBe('local') await db.destroy() }) @@ -66,13 +56,4 @@ describe.each(dialects)('accounts on $name', ({ create }) => { ) await db.destroy() }) - - it('stores the background sync setting', async () => { - const db = await setup() - await recordLogin(db, { ...login, spacesAllowed: true }) - expect((await getAccount(db, login.did))?.backgroundSync).toBe(true) - await setBackgroundSync(db, login.did, false) - expect((await getAccount(db, login.did))?.backgroundSync).toBe(false) - await db.destroy() - }) }) diff --git a/apps/server/tests/auth/oauth-client.test.ts b/apps/server/tests/auth/oauth-client.test.ts index 23f12a5..2a5970b 100644 --- a/apps/server/tests/auth/oauth-client.test.ts +++ b/apps/server/tests/auth/oauth-client.test.ts @@ -21,7 +21,7 @@ describe('createOAuthClient', () => { it('uses loopback client metadata for a loopback public URL', async () => { const db = createSqliteDb() await migrateToLatest(db) - const client = await createOAuthClient(testConfig(), db, buildScope('raw')) + const client = await createOAuthClient(testConfig(), db, buildScope('raw'), 'Test Chat') expect(String(client.clientMetadata.client_id)).toMatch(/^http:\/\/localhost/) expect(client.clientMetadata.token_endpoint_auth_method).toBe('none') }) @@ -34,9 +34,10 @@ describe('createOAuthClient', () => { PUBLIC_URL: 'https://chat.example.com', OAUTH_PRIVATE_KEYS: JSON.stringify([key.privateJwk]), }) - const client = await createOAuthClient(config, db, buildScope('permission-set')) + const client = await createOAuthClient(config, db, buildScope('permission-set'), 'Test Chat') expect(client.clientMetadata).toMatchObject({ client_id: 'https://chat.example.com/oauth-client-metadata.json', + client_name: 'Test Chat', token_endpoint_auth_method: 'private_key_jwt', jwks_uri: 'https://chat.example.com/oauth/jwks.json', dpop_bound_access_tokens: true, diff --git a/apps/server/tests/auth/pds.test.ts b/apps/server/tests/auth/pds.test.ts index 29f5224..c294100 100644 --- a/apps/server/tests/auth/pds.test.ts +++ b/apps/server/tests/auth/pds.test.ts @@ -4,11 +4,6 @@ import { createPdsClientFactory, SessionExpired } from '../../src/auth/pds.ts' import { fakeOAuth } from '../helpers/auth.ts' describe('createPdsClientFactory', () => { - it('returns a client for a restorable session', async () => { - const getClient = createPdsClientFactory(fakeOAuth()) - await expect(getClient('did:plc:alice')).resolves.toBeDefined() - }) - it('throws SessionExpired when the session has expired or been revoked', async () => { const oauth = fakeOAuth({ restore: vi.fn(async () => { diff --git a/apps/server/tests/auth/roles.test.ts b/apps/server/tests/auth/roles.test.ts index 9c98ec2..5256b56 100644 --- a/apps/server/tests/auth/roles.test.ts +++ b/apps/server/tests/auth/roles.test.ts @@ -1,26 +1,107 @@ -import { describe, expect, it } from 'vitest' -import { createRoles, RolesConfigError } from '../../src/auth/roles.ts' +import type { RoleSource } from '@scn-chat/plugin-api' +import pino from 'pino' +import { describe, expect, it, vi } from 'vitest' +import { addMember, createRole, updateRole } from '../../src/auth/role-store.ts' +import { Roles } from '../../src/auth/roles.ts' +import { migrateToLatest } from '../../src/db/migrate.ts' +import { dialects } from '../helpers/db.ts' -describe('createRoles', () => { - it('gives every user the implicit user role plus each configured role listing them', () => { - const roles = createRoles({ staff: ['did:plc:alice'], beta: ['did:plc:alice', 'did:plc:bob'] }) - expect(roles.rolesFor('did:plc:alice')).toEqual(['user', 'staff', 'beta']) - expect(roles.rolesFor('did:plc:carol')).toEqual(['user']) +const alice = { + did: 'did:plc:alice', + handle: 'alice.example.com', + pdsUrl: 'https://pds.example.com', +} + +function logged() { + const lines: Record[] = [] + const logger = pino({ level: 'warn' }, { write: (line: string) => lines.push(JSON.parse(line)) }) + return { logger, lines } +} + +describe.each(dialects)('Roles on $name', ({ create }) => { + async function setup(sources: RoleSource[] = [], sourceTimeoutMs?: number) { + const db = create() + await migrateToLatest(db) + const { logger, lines } = logged() + const roles = new Roles({ + db, + adminDids: new Set(['did:plc:boss']), + sources: () => sources, + logger, + sourceTimeoutMs, + }) + await createRole(db, { name: 'staff', description: '' }) + return { db, roles, lines } + } + + it('gives DIDs in ADMIN_DIDS the admin role from the environment', async () => { + const { roles } = await setup() + const boss = { ...alice, did: 'did:plc:boss' } + expect(await roles.grantsFor(boss)).toEqual([{ role: 'admin', source: 'environment' }]) + }) + + it('includes explicit members', async () => { + const { db, roles } = await setup() + await addMember(db, 'staff', alice.did, 'did:plc:boss') + expect(await roles.grantsFor(alice)).toEqual([{ role: 'staff', source: 'member' }]) + }) + + it('matches the PDS host exactly, and not its subdomains', async () => { + const { db, roles } = await setup() + await updateRole(db, 'staff', { pdsHosts: ['pds.example.com'] }) + expect(await roles.rolesFor(alice)).toEqual(['user', 'staff']) + expect(await roles.rolesFor({ ...alice, pdsUrl: 'https://eu.pds.example.com' })).toEqual([ + 'user', + ]) + }) + + it('matches a handle domain and its subdomains, but not names that only end the same', async () => { + const { db, roles } = await setup() + await updateRole(db, 'staff', { handleDomains: ['example.com'] }) + expect(await roles.rolesFor(alice)).toEqual(['user', 'staff']) + expect(await roles.rolesFor({ ...alice, handle: 'example.com' })).toEqual(['user', 'staff']) + expect(await roles.rolesFor({ ...alice, handle: 'alice.badexample.com' })).toEqual(['user']) + expect(await roles.rolesFor({ ...alice, handle: null })).toEqual(['user']) }) - it('refuses a role named user', () => { - expect(() => createRoles({ user: ['did:plc:alice'] })).toThrow(RolesConfigError) + it('includes roles a role source grants, asked on every call', async () => { + const rolesFor = vi.fn(async () => ['staff']) + const { roles } = await setup([{ id: 'members', rolesFor }]) + expect(await roles.grantsFor(alice)).toEqual([{ role: 'staff', source: 'plugin:members' }]) + rolesFor.mockResolvedValueOnce([]) + expect(await roles.rolesFor(alice)).toEqual(['user']) + expect(rolesFor).toHaveBeenCalledWith(alice) }) - it('refuses an invalid role name, naming it', () => { - expect(() => createRoles({ 'Bad Role': [] })).toThrow(/Bad Role/) + it('ignores a failing source with a warning and keeps the others', async () => { + const { roles, lines } = await setup([ + { id: 'broken', rolesFor: async () => Promise.reject(new Error('down')) }, + { id: 'working', rolesFor: async () => ['staff'] }, + ]) + expect(await roles.rolesFor(alice)).toEqual(['user', 'staff']) + expect(lines).toContainEqual(expect.objectContaining({ source: 'broken' })) }) - it('refuses an invalid DID, naming the role and entry', () => { - expect(() => createRoles({ staff: ['alice'] })).toThrow(/staff.*"alice"/) + it('ignores a source that takes too long', async () => { + const { roles, lines } = await setup( + [ + { + id: 'slow', + rolesFor: () => new Promise((resolve) => setTimeout(() => resolve(['staff']), 200)), + }, + ], + 20, + ) + expect(await roles.rolesFor(alice)).toEqual(['user']) + expect(lines).toContainEqual(expect.objectContaining({ source: 'slow' })) }) - it('lists the defined roles', () => { - expect([...createRoles({ staff: [] }).defined]).toEqual(['user', 'staff']) + it('ignores unknown roles and admin from a source, with a warning', async () => { + const { roles, lines } = await setup([{ id: 'greedy', rolesFor: async () => ['admin', 'vip'] }]) + expect(await roles.rolesFor(alice)).toEqual(['user']) + expect(lines.filter((line) => line.source === 'greedy').map((line) => line.role)).toEqual([ + 'admin', + 'vip', + ]) }) }) diff --git a/apps/server/tests/auth/routes.test.ts b/apps/server/tests/auth/routes.test.ts index e08dbfa..d15e892 100644 --- a/apps/server/tests/auth/routes.test.ts +++ b/apps/server/tests/auth/routes.test.ts @@ -30,13 +30,17 @@ async function signIn(app: Awaited>['app']) { return sessionCookie(res) } -describe('client metadata and keys', () => { - it('serves the client metadata and public keys', async () => { - const { app } = await setup() +describe('client metadata name', () => { + it('serves the current app name in confidential client metadata', async () => { + const oauth = fakeOAuth({ + clientMetadata: { client_id: 'https://chat.example.com/meta', client_name: 'Startup' }, + } as never) + const { app, auth } = await setup({ oauth }) + await auth.settings.set('general', { appName: 'Renamed' }, 'did:plc:admin') expect(await (await app.request('/oauth-client-metadata.json')).json()).toEqual({ - client_id: 'http://localhost', + client_id: 'https://chat.example.com/meta', + client_name: 'Renamed', }) - expect(await (await app.request('/oauth/jwks.json')).json()).toEqual({ keys: [] }) }) }) @@ -53,12 +57,6 @@ describe('GET /oauth/login', () => { expect(res.headers.get('set-cookie')).toMatch(/scn_login=[^;]+;.*HttpOnly/) }) - it('sends the user back to the login page without an identifier', async () => { - const { app } = await setup() - const res = await app.request('/oauth/login') - expect(res.headers.get('location')).toMatch(/^\/login\?error=/) - }) - it('shows an invalid_scope error from the PDS on the login page', async () => { const oauth = fakeOAuth({ authorize: vi.fn(async () => { @@ -82,22 +80,21 @@ describe('GET /oauth/login', () => { return new URL(res.headers.get('location') ?? '', 'http://x').searchParams.get('error') } - it("suggests the DID when a handle doesn't resolve", async () => { - const cause = Object.assign(new Error('Handle "alice.test" does not resolve to a DID'), { + it("explains an unknown handle and an unknown DID differently, instead of the resolver's message", async () => { + const handleCause = Object.assign(new Error('Handle "alice.test" does not resolve to a DID'), { name: 'IdentityResolverError', }) - const error = new OAuthResolverError('Failed to resolve identity: alice.test', { cause }) - expect(await loginErrorFor(error)).toBe( - "We couldn't find an account for that handle. Check the spelling, or sign in with your DID instead.", + const handle = await loginErrorFor( + new OAuthResolverError('Failed to resolve identity: alice.test', { cause: handleCause }), ) - }) - - it("says the DID wasn't found when a DID doesn't resolve", async () => { - const cause = new DidError('did:plc:nobody', 'DID not found', 'did-unknown', 404) - const error = new OAuthResolverError('Failed to resolve identity: did:plc:nobody', { cause }) - expect(await loginErrorFor(error, 'did:plc:nobody')).toBe( - "We couldn't find an account for that DID.", + const didCause = new DidError('did:plc:nobody', 'DID not found', 'did-unknown', 404) + const did = await loginErrorFor( + new OAuthResolverError('Failed to resolve identity: did:plc:nobody', { cause: didCause }), + 'did:plc:nobody', ) + expect(handle).not.toContain('Failed to resolve') + expect(did).not.toContain('Failed to resolve') + expect(handle).not.toBe(did) }) it("keeps the resolver's message for failures after the account was found", async () => { @@ -109,10 +106,13 @@ describe('GET /oauth/login', () => { }) describe('GET /oauth/callback', () => { - it('refuses a callback from a browser that did not start the sign-in, and revokes its tokens', async () => { + it.each([ + ['no login cookie', {}], + ['a login cookie for another sign-in', { headers: { cookie: 'scn_login=someone-else' } }], + ])('refuses a callback with %s, and revokes its tokens', async (_, init) => { const { app, auth } = await setup() - const res = await app.request('/oauth/callback?code=abc&state=xyz') - expect(decodeURIComponent(res.headers.get('location') ?? '')).toContain('different browser') + const res = await app.request('/oauth/callback?code=abc&state=xyz', init) + expect(res.headers.get('location')).toMatch(/^\/login\?error=/) expect(res.headers.get('set-cookie') ?? '').not.toMatch(/scn_session=[^;]/) expect(auth.oauth.revoke).toHaveBeenCalledWith('did:plc:alice') }) @@ -145,14 +145,6 @@ describe('GET /oauth/callback', () => { expect(res.headers.get('location')).toBe('/') }) - it('refuses a callback whose state does not match the login cookie', async () => { - const { app } = await setup() - const res = await app.request('/oauth/callback?code=abc&state=xyz', { - headers: { cookie: 'scn_login=someone-else' }, - }) - expect(decodeURIComponent(res.headers.get('location') ?? '')).toContain('different browser') - }) - it('sets an HttpOnly, SameSite=Lax session cookie and redirects home', async () => { const { app } = await setup() const res = await app.request('/oauth/callback?code=abc&state=xyz', loginCookie) @@ -163,15 +155,6 @@ describe('GET /oauth/callback', () => { expect(cookie).toMatch(/SameSite=Lax/) }) - it('creates a space account when the granted scope allows spaces', async () => { - const { app } = await setup() - const cookie = await signIn(app) - const me = (await (await app.request('/api/me', { headers: { cookie } })).json()) as { - storageMode: string - } - expect(me).toMatchObject({ did: 'did:plc:alice', handle: 'alice.test', storageMode: 'space' }) - }) - it('creates a local account when the PDS dropped the space permissions', async () => { const oauth = fakeOAuth({ callback: vi.fn(async () => ({ @@ -187,7 +170,7 @@ describe('GET /oauth/callback', () => { expect(me.storageMode).toBe('local') }) - it('explains the failure when a space account loses spaces support', async () => { + it('turns away a space account that loses spaces support, and revokes its tokens', async () => { const { app, db } = await setup() await signIn(app) const oauth = fakeOAuth({ @@ -206,6 +189,7 @@ describe('GET /oauth/callback', () => { expect(decodeURIComponent(res.headers.get('location') ?? '')).toContain( new SpacesLostError().message, ) + expect(oauth.revoke).toHaveBeenCalledWith('did:plc:alice') }) }) @@ -233,47 +217,17 @@ describe('API session handling', () => { expect(res.headers.get('set-cookie')).toBeNull() }) - it('revokes the stored tokens when a spaces account signs in without spaces', async () => { - const { app, db } = await setup() - await signIn(app) - const oauth = fakeOAuth({ - callback: vi.fn(async () => ({ - session: fakeSession('did:plc:alice', 'atproto'), - state: LOGIN_STATE, - })), - }) - const again = createApp({ - config: testConfig(), - db, - logger: pino({ level: 'silent' }), - auth: authDeps(db, { oauth }), - }) - await again.request('/oauth/callback?code=abc&state=xyz', loginCookie) - expect(oauth.revoke).toHaveBeenCalledWith('did:plc:alice') - }) - - it('includes the user roles in /api/me', async () => { - const { app } = await setup() - const cookie = await signIn(app) - const me = (await (await app.request('/api/me', { headers: { cookie } })).json()) as { - roles: string[] - } - expect(me.roles).toEqual(['user', 'staff']) - }) - - it('refuses a non-GET API request with a foreign Origin', async () => { + it('refuses a non-GET API request with a foreign Origin or none', async () => { const { app } = await setup() const cookie = await signIn(app) - const res = await app.request('/api/logout', { + const foreign = await app.request('/api/logout', { method: 'POST', headers: { cookie, origin: 'https://evil.test' }, }) - expect(res.status).toBe(403) - }) - - it('refuses a non-GET API request with no Origin', async () => { - const { app } = await setup() - expect((await app.request('/api/logout', { method: 'POST' })).status).toBe(403) + expect(foreign.status).toBe(403) + expect((await app.request('/api/logout', { method: 'POST', headers: { cookie } })).status).toBe( + 403, + ) }) }) diff --git a/apps/server/tests/auth/scope.test.ts b/apps/server/tests/auth/scope.test.ts index b8f9b96..721844a 100644 --- a/apps/server/tests/auth/scope.test.ts +++ b/apps/server/tests/auth/scope.test.ts @@ -6,8 +6,10 @@ import { buildScope, scopeAllowsSpaces } from '../../src/auth/scope.ts' const did = 'did:plc:alice' describe('buildScope', () => { - it('includes the permission set by default, with its NSID from the lexicon module, and blobs alongside', () => { - expect(buildScope('permission-set')).toBe(`atproto include:${nsid.permissions} blob:*/*`) + it('requests blobs alongside the permission set', () => { + expect(buildScope('permission-set').split(' ')).toEqual( + expect.arrayContaining([`include:${nsid.permissions}`, 'blob:*/*']), + ) }) it('builds raw space and blob scopes that parse as valid permissions', () => { @@ -39,6 +41,6 @@ describe('scopeAllowsSpaces', () => { }) it('throws on a space permission it cannot parse', () => { - expect(() => scopeAllowsSpaces('atproto space:not a permission', did)).toThrow(/cannot read/) + expect(() => scopeAllowsSpaces('atproto space:not a permission', did)).toThrow() }) }) diff --git a/apps/server/tests/blobs/routes.test.ts b/apps/server/tests/blobs/routes.test.ts index 676df75..b2fad56 100644 --- a/apps/server/tests/blobs/routes.test.ts +++ b/apps/server/tests/blobs/routes.test.ts @@ -60,7 +60,12 @@ async function setup(storageMode: 'space' | 'local') { db: h.db, logger: h.logger, auth: authDeps(h.db, { oauth }), - blobs: { blobsFor: stores.forAccount, services: h.services, ingesters, logger: h.logger }, + blobs: { + blobsFor: stores.forAccount, + services: h.services, + ingesters: () => ingesters, + logger: h.logger, + }, }) const cookie = sessionCookie(await app.request('/oauth/callback?code=a&state=b', loginCookie)) const upload = async (bytes: Uint8Array, type: string, name = 'file') => { @@ -164,13 +169,13 @@ describe.each(['space', 'local'] as const)('attachments for a %s account', (mode expect((await upload(new Uint8Array([1]), 'application/pdf', '%E0%A4%A')).status).toBe(400) }) - it('lists the file types it can read', async () => { + it('lists the file types the installed ingesters can read', async () => { const { app, cookie } = await setup(mode) const res = await app.request('/api/attachments/types', { headers: { cookie } }) - expect(await res.json()).toEqual({ - images: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], - files: ['application/pdf', 'application/x-huge'], - }) + expect(((await res.json()) as { files: string[] }).files).toEqual([ + 'application/pdf', + 'application/x-huge', + ]) }) }) diff --git a/apps/server/tests/blobs/store.test.ts b/apps/server/tests/blobs/store.test.ts index d62de46..b7dc1eb 100644 --- a/apps/server/tests/blobs/store.test.ts +++ b/apps/server/tests/blobs/store.test.ts @@ -27,17 +27,6 @@ describe('LocalBlobStore', () => { }) }) - it('reads a stored blob back with its type', async () => { - const { store, account } = await setup() - const ref = (await store.put(account, new Uint8Array([1, 2, 3]), 'image/png')) as { - ref: { $link: string } - } - expect(await store.get(account, 'at://x', ref.ref.$link)).toEqual({ - bytes: new Uint8Array([1, 2, 3]), - mimeType: 'image/png', - }) - }) - it('keeps each owner blobs separate', async () => { const { store, account } = await setup() const ref = (await store.put(account, new Uint8Array([9]), 'image/png')) as { diff --git a/apps/server/tests/config.test.ts b/apps/server/tests/config.test.ts index 0e70cb0..3c55f3b 100644 --- a/apps/server/tests/config.test.ts +++ b/apps/server/tests/config.test.ts @@ -1,99 +1,62 @@ -import { mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { - ConfigError, - DEFAULT_SYSTEM_PROMPT, - interpolate, - loadConfig, - readConfigFile, -} from '../src/config.ts' +import { type ConfigError, loadConfig } from '../src/config.ts' const SECRET_KEY = Buffer.alloc(32, 1).toString('base64') -const env = { SECRET_KEY } +const env = { SECRET_KEY, ADMIN_DIDS: 'did:plc:admin' } + +/** The variables loadConfig names as at fault. */ +function faultsIn(vars: Record): string[] { + try { + loadConfig(vars) + } catch (err) { + return (err as ConfigError).names.sort() + } + throw new Error('expected loadConfig to fail') +} describe('loadConfig', () => { - it('returns typed defaults when config.yml sets nothing', () => { - const config = loadConfig({ env, file: {} }) - expect(config).toMatchObject({ - nodeEnv: 'development', - port: 3000, - appName: 'SCN Chat', - oauthScopeMode: 'permission-set', - }) - expect(config.turns).toEqual({ - ratePerMinute: 10, - maxSteps: 8, - timeoutMs: 600_000, - backfillWindowMs: 3_600_000, - systemPrompt: DEFAULT_SYSTEM_PROMPT, - }) - }) - - it('reads public settings from config.yml', () => { + it('trims a trailing slash from the public URL and splits ADMIN_DIDS', () => { const config = loadConfig({ - env, - file: { - app: { name: 'My Chat', publicUrl: 'https://chat.example.com/', port: 8080 }, - turns: { maxSteps: 3 }, - roles: { staff: ['did:plc:a'] }, - }, + ...env, + PUBLIC_URL: 'https://chat.example.com/', + ADMIN_DIDS: 'did:plc:a, did:web:b.example.com', }) expect(config).toMatchObject({ - appName: 'My Chat', publicUrl: 'https://chat.example.com', - port: 8080, - roles: { staff: ['did:plc:a'] }, - }) - expect(config.turns.maxSteps).toBe(3) - }) - - it('lets environment variables override public settings', () => { - const config = loadConfig({ - env: { ...env, PORT: '9000', OAUTH_SCOPE_MODE: 'raw', TURN_MAX_STEPS: '2' }, - file: { app: { port: 8080 } }, + adminDids: ['did:plc:a', 'did:web:b.example.com'], }) - expect(config).toMatchObject({ port: 9000, oauthScopeMode: 'raw' }) - expect(config.turns.maxSteps).toBe(2) }) - it('fails naming the setting when a value in config.yml is invalid', () => { - expect(() => loadConfig({ env, file: { app: { port: 'not-a-port' } } })).toThrow(/app\.port/) + it('fails naming ADMIN_DIDS when it is missing or holds an invalid DID', () => { + expect(faultsIn({ SECRET_KEY })).toEqual(['ADMIN_DIDS']) + expect(faultsIn({ ...env, ADMIN_DIDS: 'did:plc:a,alice' })).toEqual(['ADMIN_DIDS']) + expect(() => loadConfig({ ...env, ADMIN_DIDS: 'did:plc:a,alice' })).toThrow(/alice/) }) - it('refuses unknown top-level sections', () => { - expect(() => loadConfig({ env, file: { aap: {} } })).toThrow(ConfigError) - }) - - it('reports every invalid setting and secret at once', () => { - try { - loadConfig({ env: {}, file: { app: { port: 0, publicUrl: 'nope' } } }) - expect.unreachable() - } catch (err) { - expect((err as ConfigError).names.sort()).toEqual(['SECRET_KEY', 'app.port', 'app.publicUrl']) - } + it('reports every invalid variable at once', () => { + expect(faultsIn({ PORT: '0', PUBLIC_URL: 'nope' })).toEqual([ + 'ADMIN_DIDS', + 'PORT', + 'PUBLIC_URL', + 'SECRET_KEY', + ]) }) it('fails naming SECRET_KEY when it is not 32 bytes', () => { - expect(() => - loadConfig({ env: { SECRET_KEY: Buffer.alloc(16).toString('base64') }, file: {} }), - ).toThrow(/SECRET_KEY/) + expect(faultsIn({ ...env, SECRET_KEY: Buffer.alloc(16).toString('base64') })).toEqual([ + 'SECRET_KEY', + ]) }) it('parses retired keys from a comma-separated list', () => { const old = Buffer.alloc(32, 2).toString('base64') - expect( - loadConfig({ env: { ...env, SECRET_KEYS_OLD: `${old}, ${old}` }, file: {} }).oldSecretKeys, - ).toHaveLength(2) + expect(loadConfig({ ...env, SECRET_KEYS_OLD: `${old}, ${old}` }).oldSecretKeys).toHaveLength(2) }) - it('treats empty values in .env as unset', () => { - const config = loadConfig({ - env: { ...env, OAUTH_PRIVATE_KEYS: '', SECRET_KEYS_OLD: '', DATABASE_URL: '' }, - file: {}, - }) - expect(config).toMatchObject({ + it('treats empty values as unset', () => { + expect( + loadConfig({ ...env, OAUTH_PRIVATE_KEYS: '', SECRET_KEYS_OLD: '', DATABASE_URL: '' }), + ).toMatchObject({ oauthPrivateKeys: [], oldSecretKeys: [], databaseUrl: 'sqlite:./data/scn-chat.sqlite', @@ -101,69 +64,17 @@ describe('loadConfig', () => { }) it('fails naming OAUTH_PRIVATE_KEYS when it is not a JSON array of keys', () => { - expect(() => loadConfig({ env: { ...env, OAUTH_PRIVATE_KEYS: 'not json' }, file: {} })).toThrow( - /OAUTH_PRIVATE_KEYS/, - ) + expect(faultsIn({ ...env, OAUTH_PRIVATE_KEYS: 'not json' })).toEqual(['OAUTH_PRIVATE_KEYS']) }) it('refuses an HTTP public URL, missing keys, and raw scopes in production', () => { - try { - loadConfig({ - env: { ...env, NODE_ENV: 'production' }, - file: { app: { publicUrl: 'http://chat.example.com' }, auth: { scopeMode: 'raw' } }, - }) - expect.unreachable() - } catch (err) { - expect((err as ConfigError).names.sort()).toEqual([ - 'OAUTH_PRIVATE_KEYS', - 'app.publicUrl', - 'auth.scopeMode', - ]) - } - }) - - it('returns a frozen object with the config directory for resolving plugins', () => { - const config = loadConfig({ env, file: {}, configPath: '/srv/scn/config.yml' }) - expect(Object.isFrozen(config)).toBe(true) - expect(config.configDir).toBe('/srv/scn') - }) -}) - -describe('interpolate', () => { - it('replaces references with environment values', () => { - expect(interpolate({ a: '${KEY}', b: 'Bearer ${KEY}' }, { KEY: 'sk-1' })).toEqual({ - a: 'sk-1', - b: 'Bearer sk-1', - }) - }) - - it('leaves out a value that is exactly a reference to a missing variable', () => { - expect(interpolate({ options: { apiKey: '${MISSING}', name: 'x' } }, {})).toEqual({ - options: { name: 'x' }, - }) - }) - - it('fails naming the variable when a longer string references a missing one', () => { - expect(() => interpolate({ url: 'https://${HOST}/v1' }, {})).toThrow(/HOST/) - }) - - it('reaches plugin options inside lists', () => { - const file = { plugins: [{ package: 'p', options: { apiKey: '${KEY}' } }] } - expect(interpolate(file, { KEY: 'k' })).toEqual({ - plugins: [{ package: 'p', options: { apiKey: 'k' } }], - }) - }) -}) - -describe('readConfigFile', () => { - it('fails with a message naming config.example.yml when the file is missing', () => { - expect(() => readConfigFile('/nonexistent/config.yml')).toThrow(/config\.example\.yml/) - }) - - it('reports invalid YAML as a config error with its line', () => { - const path = join(mkdtempSync(join(tmpdir(), 'scn-config-')), 'config.yml') - writeFileSync(path, 'models: []\n - provider: x\n') - expect(() => readConfigFile(path)).toThrow(ConfigError) - expect(() => readConfigFile(path)).toThrow(/not valid YAML.*line 2/s) + expect( + faultsIn({ + ...env, + NODE_ENV: 'production', + PUBLIC_URL: 'http://chat.example.com', + OAUTH_SCOPE_MODE: 'raw', + }), + ).toEqual(['OAUTH_PRIVATE_KEYS', 'OAUTH_SCOPE_MODE', 'PUBLIC_URL']) }) }) diff --git a/apps/server/tests/db/index.test.ts b/apps/server/tests/db/index.test.ts index c3bda09..20b0b72 100644 --- a/apps/server/tests/db/index.test.ts +++ b/apps/server/tests/db/index.test.ts @@ -29,16 +29,7 @@ describe('createDb', () => { expect(rows[0]?.journal_mode).toBe('wal') }) - it('creates a Postgres database for a postgres:// URL without connecting', () => { - const db = createDb('postgres://user:pass@localhost:5432/scn') - cleanup.push(() => db.destroy()) - expect(db).toBeDefined() - }) - - it('rejects an unsupported scheme with a clear message', () => { + it('rejects an unsupported scheme', () => { expect(() => createDb('mysql://localhost/scn')).toThrow(UnsupportedDatabaseError) - expect(() => createDb('mysql://localhost/scn')).toThrow( - /Unsupported DATABASE_URL scheme "mysql:"/, - ) }) }) diff --git a/apps/server/tests/db/migrate.test.ts b/apps/server/tests/db/migrate.test.ts index fc574da..ffb4d61 100644 --- a/apps/server/tests/db/migrate.test.ts +++ b/apps/server/tests/db/migrate.test.ts @@ -1,15 +1,12 @@ -import { type Kysely, sql } from 'kysely' +import type { Kysely } from 'kysely' import type { Migration } from 'kysely/migration' -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { migrateToLatest } from '../../src/db/migrate.ts' import { dialects } from '../helpers/db.ts' -const order: string[] = [] - const fixtures: Record = { '0001_first': { up: async (db: Kysely) => { - order.push('0001_first') await db.schema .createTable('fixture') .addColumn('id', 'text', (col) => col.primaryKey()) @@ -18,31 +15,16 @@ const fixtures: Record = { }, '0002_second': { up: async (db: Kysely) => { - order.push('0002_second') await db.schema.alterTable('fixture').addColumn('value', 'text').execute() }, }, } -afterEach(() => { - order.length = 0 -}) - describe.each(dialects)('migrateToLatest on $name', ({ create }) => { - it('applies pending migrations in order', async () => { + it('reports the migrations it applied, and none when run again', async () => { const db = create() - const { applied } = await migrateToLatest(db, fixtures) - expect(applied).toEqual(['0001_first', '0002_second']) - expect(order).toEqual(['0001_first', '0002_second']) - await sql`insert into fixture (id, value) values ('a', 'b')`.execute(db) - await db.destroy() - }) - - it('applies nothing when run again', async () => { - const db = create() - await migrateToLatest(db, fixtures) - const { applied } = await migrateToLatest(db, fixtures) - expect(applied).toEqual([]) + expect((await migrateToLatest(db, fixtures)).applied).toEqual(['0001_first', '0002_second']) + expect((await migrateToLatest(db, fixtures)).applied).toEqual([]) await db.destroy() }) @@ -58,10 +40,4 @@ describe.each(dialects)('migrateToLatest on $name', ({ create }) => { await expect(migrateToLatest(db, broken)).rejects.toThrow('boom') await db.destroy() }) - - it('runs the app migrations cleanly', async () => { - const db = create() - await expect(migrateToLatest(db)).resolves.toBeDefined() - await db.destroy() - }) }) diff --git a/apps/server/tests/fixtures/plugins/good/package.json b/apps/server/tests/fixtures/plugins/good/package.json index d30ffe4..d85ca84 100644 --- a/apps/server/tests/fixtures/plugins/good/package.json +++ b/apps/server/tests/fixtures/plugins/good/package.json @@ -1,5 +1,10 @@ { "name": "good-plugin", + "description": "The good test plugin", + "version": "1.0.0", "type": "module", + "keywords": [ + "scn-chat-plugin" + ], "exports": "./index.ts" } diff --git a/apps/server/tests/fixtures/plugins/noexport/package.json b/apps/server/tests/fixtures/plugins/noexport/package.json index 9dac50f..b5ed6c5 100644 --- a/apps/server/tests/fixtures/plugins/noexport/package.json +++ b/apps/server/tests/fixtures/plugins/noexport/package.json @@ -1,5 +1,10 @@ { "name": "noexport-plugin", + "description": "The noexport test plugin", + "version": "1.0.0", "type": "module", + "keywords": [ + "scn-chat-plugin" + ], "exports": "./index.ts" } diff --git a/apps/server/tests/helpers/admin.ts b/apps/server/tests/helpers/admin.ts new file mode 100644 index 0000000..df2d1cc --- /dev/null +++ b/apps/server/tests/helpers/admin.ts @@ -0,0 +1,61 @@ +import pino from 'pino' +import { vi } from 'vitest' +import { migrateToLatest } from '../../src/db/migrate.ts' +import type { InstalledPlugins } from '../../src/plugins/installed.ts' +import { createServer } from '../../src/server.ts' +import { fakeIdentity, fakeOAuth, fakeSession, LOGIN_STATE, loginCookie, ORIGIN } from './auth.ts' +import { TEST_ADMIN, testConfig } from './config.ts' +import { createSqliteDb } from './db.ts' +import { installedFrom } from './plugins.ts' + +type Json = Record + +/** A whole server with TEST_ADMIN as its admin, and helpers to call it as any DID. */ +export async function adminHarness(options: { installed?: InstalledPlugins } = {}) { + const db = createSqliteDb() + await migrateToLatest(db) + let signingIn = TEST_ADMIN + const identity = fakeIdentity() + const server = await createServer({ + config: testConfig(), + db, + logger: pino({ level: 'silent' }), + installed: options.installed ?? installedFrom({}), + oauth: fakeOAuth({ + callback: vi.fn(async () => ({ + session: fakeSession(signingIn, 'atproto'), + state: LOGIN_STATE, + })), + }), + identity, + startBackgroundJobs: false, + }) + const cookies = new Map() + /** Sign in as the DID, returning the redirect. Viewers come back through a share link. */ + const signIn = async (did: string, next?: string) => { + signingIn = did + const res = await server.app.request('/oauth/callback?code=a&state=b', { + headers: { + cookie: `${loginCookie.headers.cookie}${next ? `; scn_next=${encodeURIComponent(next)}` : ''}`, + }, + }) + const match = (res.headers.get('set-cookie') ?? '').match(/scn_session=([^;]+)/) + if (match) cookies.set(did, `scn_session=${match[1]}`) + return res + } + /** Call the API as the DID, signing in first when needed. */ + const call = async (method: string, path: string, body?: unknown, as = TEST_ADMIN) => { + if (!cookies.has(as)) await signIn(as) + const res = await server.app.request(`/api${path}`, { + method, + headers: { + cookie: cookies.get(as) ?? '', + origin: ORIGIN, + 'content-type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + return { status: res.status, body: (await res.json()) as Json } + } + return { ...server, db, identity, signIn, call } +} diff --git a/apps/server/tests/helpers/auth.ts b/apps/server/tests/helpers/auth.ts index 7ccc993..ee9b518 100644 --- a/apps/server/tests/helpers/auth.ts +++ b/apps/server/tests/helpers/auth.ts @@ -3,11 +3,11 @@ import pino from 'pino' import { vi } from 'vitest' import type { IdentityResolver } from '../../src/auth/identity.ts' import type { OAuthClientLike } from '../../src/auth/oauth-client.ts' -import { createRoles } from '../../src/auth/roles.ts' import type { AuthDeps } from '../../src/auth/routes.ts' import { buildScope } from '../../src/auth/scope.ts' import type { Db } from '../../src/db/index.ts' import { testConfig } from './config.ts' +import { testAccess } from './settings.ts' export const ORIGIN = 'http://127.0.0.1:3000' @@ -52,7 +52,7 @@ export function authDeps(db: Db, overrides: Partial = {}): AuthDeps { logger: pino({ level: 'silent' }), oauth: fakeOAuth(), identity: fakeIdentity(), - roles: createRoles({ staff: ['did:plc:alice'] }), + ...testAccess(db), scope: buildScope('raw'), ...overrides, } diff --git a/apps/server/tests/helpers/config.ts b/apps/server/tests/helpers/config.ts index b0f5a3e..0f2111f 100644 --- a/apps/server/tests/helpers/config.ts +++ b/apps/server/tests/helpers/config.ts @@ -2,10 +2,15 @@ import { loadConfig } from '../../src/config.ts' export const TEST_SECRET_KEY = Buffer.alloc(32, 1).toString('base64') -/** A test config: env holds secrets and overrides, file holds config.yml's contents. */ -export function testConfig(env: Record = {}, file: Record = {}) { +/** The admin every test config lists in ADMIN_DIDS. */ +export const TEST_ADMIN = 'did:plc:admin' + +/** A test config from the environment, with the secrets and an admin filled in. */ +export function testConfig(env: Record = {}) { return loadConfig({ - env: { NODE_ENV: 'test', SECRET_KEY: TEST_SECRET_KEY, ...env }, - file: { app: { name: 'Test Chat' }, ...file }, + NODE_ENV: 'test', + SECRET_KEY: TEST_SECRET_KEY, + ADMIN_DIDS: TEST_ADMIN, + ...env, }) } diff --git a/apps/server/tests/helpers/plugins.ts b/apps/server/tests/helpers/plugins.ts new file mode 100644 index 0000000..10e5add --- /dev/null +++ b/apps/server/tests/helpers/plugins.ts @@ -0,0 +1,22 @@ +import type { Plugin } from '@scn-chat/plugin-api' +import type { z } from 'zod' +import type { InstalledPlugin, InstalledPlugins } from '../../src/plugins/installed.ts' + +type Entry = { factory: (options: never) => Plugin; optionsSchema?: z.ZodType; multiple?: boolean } + +/** Installed plugins built from factories, keyed by package name, without importing packages. */ +export function installedFrom(entries: Record): InstalledPlugins { + return new Map( + Object.entries(entries).map(([name, entry]): [string, InstalledPlugin] => [ + name, + { + package: name, + description: `${name} for tests`, + version: '1.0.0', + factory: entry.factory as (options: unknown) => Plugin, + ...(entry.optionsSchema ? { optionsSchema: entry.optionsSchema } : {}), + multiple: entry.multiple ?? false, + }, + ]), + ) +} diff --git a/apps/server/tests/helpers/settings.ts b/apps/server/tests/helpers/settings.ts new file mode 100644 index 0000000..2f0af28 --- /dev/null +++ b/apps/server/tests/helpers/settings.ts @@ -0,0 +1,23 @@ +import pino from 'pino' +import { Access } from '../../src/auth/access.ts' +import { Roles } from '../../src/auth/roles.ts' +import type { Db } from '../../src/db/index.ts' +import type { Settings } from '../../src/settings/schemas.ts' +import { SettingsStore } from '../../src/settings/store.ts' +import { TEST_ADMIN } from './config.ts' + +const logger = pino({ level: 'silent' }) + +/** Settings for tests, with the defaults unless the values say otherwise. */ +export const testSettings = (db: Db, values: Partial = {}) => + new SettingsStore(db, logger, values) + +export const testRoles = (db: Db, options: Partial[0]> = {}) => + new Roles({ db, adminDids: new Set([TEST_ADMIN]), sources: () => [], logger, ...options }) + +/** Roles, settings, and access over one database. */ +export function testAccess(db: Db, values: Partial = {}) { + const settings = testSettings(db, values) + const roles = testRoles(db) + return { settings, roles, access: new Access({ db, roles, settings, logger }) } +} diff --git a/apps/server/tests/helpers/spaces.ts b/apps/server/tests/helpers/spaces.ts index 82d5485..22b1704 100644 --- a/apps/server/tests/helpers/spaces.ts +++ b/apps/server/tests/helpers/spaces.ts @@ -28,7 +28,11 @@ export const userMessage = (text: string, extra: Record = {}) = /** A spaces account on a fake PDS, with the services and sync engine the server would build. */ export async function spacesHarness( - options: { backfillWindowMs?: number; storageMode?: 'space' | 'local' } = {}, + options: { + backfillWindowMs?: number + storageMode?: 'space' | 'local' + hasAccess?: (did: string) => Promise + } = {}, ) { const db = createSqliteDb() await migrateToLatest(db) @@ -68,7 +72,8 @@ export async function spacesHarness( resolveSigningKey: async () => pds.keypair.did(), publicUrl: 'https://chat.example.com', logger, - backfillWindowMs: options.backfillWindowMs ?? 60 * 60_000, + backfillWindowMs: () => options.backfillWindowMs ?? 60 * 60_000, + hasAccess: options.hasAccess ?? (async () => true), }) /** Write as another client would, straight to the PDS, bypassing the server's services. */ const external = services.storeFor(account) diff --git a/apps/server/tests/helpers/turns.ts b/apps/server/tests/helpers/turns.ts index 390ec63..0f97274 100644 --- a/apps/server/tests/helpers/turns.ts +++ b/apps/server/tests/helpers/turns.ts @@ -1,9 +1,10 @@ import type { LanguageModelV4, ModelProvider } from '@scn-chat/plugin-api' import { MockLanguageModelV4, simulateReadableStream } from 'ai/test' -import { createRoles } from '../../src/auth/roles.ts' -import type { ModelConfig } from '../../src/config.ts' -import type { PluginHost } from '../../src/plugins/host.ts' +import pino from 'pino' +import { loadPlugins, type PluginHost } from '../../src/plugins/host.ts' import { Registry } from '../../src/plugins/registry.ts' +import { RuntimeHolder } from '../../src/plugins/runtime.ts' +import { type AdminModel, saveAdminModel } from '../../src/providers/admin-models.ts' import { ModelCatalog } from '../../src/providers/catalog.ts' import { SecretBox } from '../../src/secrets.ts' import { type TurnBlobs, TurnRunner } from '../../src/turns/runner.ts' @@ -73,8 +74,11 @@ export async function turnsHarness( options: { storageMode?: 'space' | 'local' model?: () => LanguageModelV4 - adminModels?: Partial[] + adminModels?: Partial[] host?: PluginHost + hasAccess?: (did: string) => Promise + appName?: () => string + rolesOf?: (did: string) => Promise ratePerMinute?: number timeoutMs?: number systemPrompt?: string @@ -98,25 +102,44 @@ export async function turnsHarness( const providers = new Registry('provider', (p) => p.id) providers.register(provider, 'test') const caps = { vision: true, reasoning: true, tools: true } - const adminModels = (options.adminModels ?? [{ id: 'default-model', default: true }]).map( - (m) => ({ - provider: 'fake', - id: 'm', - name: 'M', - capabilities: caps, - roles: ['user'], - ...m, - }), - ) as ModelConfig[] + for (const m of options.adminModels ?? [{ id: 'default-model', default: true }]) { + await saveAdminModel( + h.db, + { + provider: 'fake', + id: 'm', + name: 'M', + capabilities: caps, + roles: ['user'], + default: false, + ...m, + }, + 'did:plc:admin', + ) + } const catalog = new ModelCatalog({ db: h.db, box: new SecretBox(Buffer.alloc(32, 8)), providers, - adminModels, - roles: createRoles(), + rolesOf: async () => ['user'], guardedFetch: fetch, logger: h.logger, }) + const host = + options.host ?? + (await loadPlugins([], { + services: {} as never, + logger: pino({ level: 'silent' }), + app: { name: 'Test Chat', publicUrl: 'http://x' }, + })) + const plugins = new RuntimeHolder({ host, catalog, statuses: new Map() }, h.logger) + const settings = { + ratePerMinute: options.ratePerMinute ?? 100, + maxSteps: 4, + timeoutMs: options.timeoutMs ?? 10_000, + backfillWindowMs: 60 * 60_000, + systemPrompt: options.systemPrompt ?? '', + } const stored = new Map() const blobs: TurnBlobs = { reader: () => async (cid) => ({ @@ -138,18 +161,13 @@ export async function turnsHarness( const runner = new TurnRunner({ db: h.db, services: h.services, - catalog, - host: options.host, + plugins, hub, blobs, - config: { - ratePerMinute: options.ratePerMinute ?? 100, - maxSteps: 4, - timeoutMs: options.timeoutMs ?? 10_000, - backfillWindowMs: 60 * 60_000, - systemPrompt: options.systemPrompt ?? '', - }, - appName: 'Test Chat', + settings: () => settings, + appName: options.appName ?? (() => 'Test Chat'), + hasAccess: options.hasAccess ?? (async () => true), + rolesOf: options.rolesOf ?? (async () => ['user']), toolFetch: fetch, logger: h.logger, ...(options.now ? { now: options.now } : {}), @@ -161,5 +179,17 @@ export async function turnsHarness( const { messages } = await h.chats.getConversation(skey) return new Map(messages.map((m) => [m.rkey, m.value])) } - return { ...h, runner, hub, catalog, created, setModel, messages, blobs, stored } + return { + ...h, + runner, + hub, + catalog, + plugins, + settings, + created, + setModel, + messages, + blobs, + stored, + } } diff --git a/apps/server/tests/lex-errors.test.ts b/apps/server/tests/lex-errors.test.ts index c52e830..6a1f147 100644 --- a/apps/server/tests/lex-errors.test.ts +++ b/apps/server/tests/lex-errors.test.ts @@ -9,20 +9,15 @@ describe('pdsFailure', () => { (code) => { expect( pdsFailure(pdsError(403, code, 'Missing required scope "blob:application/pdf"')), - ).toEqual({ - status: 403, - error: 'ScopeMissing', - message: "Your sign-in doesn't grant this permission. Sign out and back in to grant it.", - }) + ).toMatchObject({ status: 403, error: 'ScopeMissing' }) }, ) it("passes on another PDS error's code and message as a 502", () => { - expect(pdsFailure(pdsError(400, 'InvalidRecord', 'Record is too large'))).toEqual({ - status: 502, - error: 'PdsError', - message: 'Your PDS refused the request (InvalidRecord): Record is too large', - }) + const failure = pdsFailure(pdsError(400, 'InvalidRecord', 'Record is too large')) + expect(failure).toMatchObject({ status: 502, error: 'PdsError' }) + expect(failure?.message).toContain('InvalidRecord') + expect(failure?.message).toContain('Record is too large') }) it('redacts anything that looks like a secret in the message', () => { diff --git a/apps/server/tests/plugins/hooks.test.ts b/apps/server/tests/plugins/hooks.test.ts index 773138c..e832c4c 100644 --- a/apps/server/tests/plugins/hooks.test.ts +++ b/apps/server/tests/plugins/hooks.test.ts @@ -60,16 +60,7 @@ describe('HookRunner.action', () => { expect(later).toHaveBeenCalledOnce() expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ pluginId: 'broken' }), - 'plugin action failed', + expect.anything(), ) }) }) - -describe('HookRunner.describe', () => { - it('reports the resolved handler order', () => { - const hooks = new HookRunner() - hooks.add('turn:after', 'late', 1, () => {}, 'post') - hooks.add('turn:after', 'early', 0, () => {}) - expect(hooks.describe()).toEqual({ 'turn:after': ['early', 'late'] }) - }) -}) diff --git a/apps/server/tests/plugins/host.test.ts b/apps/server/tests/plugins/host.test.ts index a98bb84..1eb2707 100644 --- a/apps/server/tests/plugins/host.test.ts +++ b/apps/server/tests/plugins/host.test.ts @@ -1,7 +1,13 @@ -import { definePlugin, type ModelProvider, type Plugin } from '@scn-chat/plugin-api' +import { + definePlugin, + type ModelProvider, + type Plugin, + type PluginContext, +} from '@scn-chat/plugin-api' import pino from 'pino' import { beforeEach, describe, expect, it, vi } from 'vitest' import { loadPlugins, PluginLoadError, type PluginServices } from '../../src/plugins/host.ts' +import { DuplicateRegistrationError } from '../../src/plugins/registry.ts' const logger = pino({ level: 'silent' }) const app = { name: 'Test', publicUrl: 'http://127.0.0.1:3000' } @@ -11,6 +17,8 @@ beforeEach(() => { generateText: vi.fn(async () => ({ text: 'generated', finishReason: 'stop' })), updateInfo: vi.fn(async () => {}), userSettings: vi.fn(async () => ({ enabled: true })), + suspendAccount: vi.fn(async () => true), + restoreAccount: vi.fn(async () => true), } }) @@ -20,19 +28,7 @@ const plugin = (id: string, setup: Plugin['setup'] = () => {}, apiVersion = 1) = const provider = (id: string) => ({ id }) as ModelProvider describe('loadPlugins', () => { - it('calls each setup once, in config order', async () => { - const calls: string[] = [] - await loadPlugins( - [ - plugin('first', () => void calls.push('first')), - plugin('second', () => void calls.push('second')), - ], - { services, logger, app }, - ) - expect(calls).toEqual(['first', 'second']) - }) - - it('waits for async setups before loading the next plugin', async () => { + it('calls each setup in config order, waiting for async ones', async () => { const calls: string[] = [] const slow = plugin('slow', async () => { await new Promise((resolve) => setTimeout(resolve, 10)) @@ -46,49 +42,43 @@ describe('loadPlugins', () => { expect(calls).toEqual(['slow', 'fast']) }) - it('fails when two plugins share an ID, naming it', async () => { - await expect( - loadPlugins([plugin('dup'), plugin('dup')], { services, logger, app }), - ).rejects.toThrow(/Two plugins use the ID "dup"/) - }) - - it('fails for an unsupported apiVersion, naming the plugin and version', async () => { - const promise = loadPlugins([plugin('future', () => {}, 2)], { services, logger, app }) - await expect(promise).rejects.toBeInstanceOf(PluginLoadError) - await expect(promise).rejects.toThrow(/"future".*version 2/) - }) - - it('fails for an invalid plugin ID', async () => { - await expect(loadPlugins([plugin('Bad ID')], { services, logger, app })).rejects.toThrow( - /must match/, + it.each([ + ['a duplicate ID', [plugin('dup'), plugin('dup')]], + ['an unsupported apiVersion', [plugin('future', () => {}, 2)]], + ['an invalid ID', [plugin('Bad ID')]], + ])('refuses %s', async (_case, plugins) => { + await expect(loadPlugins(plugins, { services, logger, app })).rejects.toBeInstanceOf( + PluginLoadError, ) }) - it('fails when two plugins register the same provider ID', async () => { - const a = plugin('a', (ctx) => ctx.providers.register(provider('anthropic'))) - const b = plugin('b', (ctx) => ctx.providers.register(provider('anthropic'))) - await expect(loadPlugins([a, b], { services, logger, app })).rejects.toThrow(/anthropic/) - }) - - it('fails when two plugins register the same tool name or ingester ID', async () => { - const tool = { - name: 'search', - description: '', - inputSchema: {} as never, - run: async () => null, - } - const toolA = plugin('a', (ctx) => ctx.tools.register(tool)) - const toolB = plugin('b', (ctx) => ctx.tools.register(tool)) - await expect(loadPlugins([toolA, toolB], { services, logger, app })).rejects.toThrow(/search/) - const ingester = { - id: 'pdf', - accepts: ['application/pdf'], - method: 'text' as const, - ingest: async () => ({ text: '' }), - } - const ingA = plugin('a', (ctx) => ctx.ingesters.register(ingester)) - const ingB = plugin('b', (ctx) => ctx.ingesters.register(ingester)) - await expect(loadPlugins([ingA, ingB], { services, logger, app })).rejects.toThrow(/pdf/) + it.each<[string, Plugin['setup']]>([ + ['provider', (ctx) => ctx.providers.register(provider('anthropic'))], + [ + 'tool', + (ctx) => + ctx.tools.register({ + name: 'search', + description: '', + inputSchema: {} as never, + run: async () => null, + }), + ], + [ + 'ingester', + (ctx) => + ctx.ingesters.register({ + id: 'pdf', + accepts: ['application/pdf'], + method: 'text', + ingest: async () => ({ text: '' }), + }), + ], + ['role source', (ctx) => ctx.roleSources.register({ id: 'members', rolesFor: async () => [] })], + ])('fails when two plugins register the same %s', async (_kind, setup) => { + await expect( + loadPlugins([plugin('a', setup), plugin('b', setup)], { services, logger, app }), + ).rejects.toBeInstanceOf(DuplicateRegistrationError) }) it("lets a plugin reach other plugins' ingesters by MIME type, with the highest priority winning", async () => { @@ -117,36 +107,6 @@ describe('loadPlugins', () => { expect(found).toEqual({ accepts: true, text: 'ocr', other: undefined }) }) - it('says no ingester accepts a type none registered', async () => { - let accepts: boolean | undefined - const probe = plugin('probe', (ctx) => { - accepts = ctx.ingesters.accepts('application/pdf') - }) - await loadPlugins([probe], { services, logger, app }) - expect(accepts).toBe(false) - }) - - it('gives plugins the services through their context', async () => { - let seen: unknown - const reader = plugin('reader', async (ctx) => { - seen = await ctx.userSettings('did:plc:a') - await ctx.conversations.updateInfo( - 'did:plc:a', - 'at://x', - { title: 'T' }, - { unlessUserTitled: true }, - ) - }) - await loadPlugins([reader], { services, logger, app }) - expect(seen).toEqual({ enabled: true }) - expect(services.updateInfo).toHaveBeenCalledWith( - 'did:plc:a', - 'at://x', - { title: 'T' }, - { unlessUserTitled: true }, - ) - }) - it('runs registered cleanup at close, newest first', async () => { const order: string[] = [] const host = await loadPlugins( @@ -160,3 +120,103 @@ describe('loadPlugins', () => { expect(order).toEqual(['b', 'a']) }) }) + +describe('loadPlugins with onFailure', () => { + it('skips a plugin whose setup throws, discards what it registered, and keeps the rest', async () => { + const closed: string[] = [] + const failures: [number, string][] = [] + const host = await loadPlugins( + [ + plugin('broken', (ctx) => { + ctx.providers.register(provider('half')) + ctx.onClose(() => void closed.push('broken')) + throw new Error('boom') + }), + plugin('fine', (ctx) => ctx.providers.register(provider('whole'))), + ], + { services, logger, app }, + { onFailure: (index, error) => void failures.push([index, error.message]) }, + ) + expect(failures).toEqual([[0, 'boom']]) + expect(host.plugins.map((p) => p.id)).toEqual(['fine']) + expect(host.providers.get('half')).toBeUndefined() + expect(host.providers.get('whole')).toBeDefined() + expect(closed).toEqual(['broken']) + }) + + it('skips the later of two plugins that register the same provider, keeping the first', async () => { + const failures: number[] = [] + const host = await loadPlugins( + [ + plugin('a', (ctx) => ctx.providers.register(provider('same'))), + plugin('b', (ctx) => ctx.providers.register(provider('same'))), + ], + { services, logger, app }, + { onFailure: (index) => void failures.push(index) }, + ) + expect(failures).toEqual([1]) + expect(host.providers.owner('same')).toBe('a') + }) + + it('closes the plugins already loaded when one fails without onFailure', async () => { + const closed: string[] = [] + await expect( + loadPlugins( + [ + plugin('first', (ctx) => ctx.onClose(() => void closed.push('first'))), + plugin('second', () => { + throw new Error('boom') + }), + ], + { services, logger, app }, + ), + ).rejects.toThrow('boom') + expect(closed).toEqual(['first']) + }) + + it('suspends and restores accounts on behalf of the plugin', async () => { + let changed: boolean[] = [] + await loadPlugins( + [ + plugin('members', async (ctx) => { + changed = [ + await ctx.accounts.suspend('did:plc:bob', { reason: 'Application revoked' }), + await ctx.accounts.restore('did:plc:bob'), + ] + }), + ], + { services, logger, app }, + ) + expect(changed).toEqual([true, true]) + expect(services.suspendAccount).toHaveBeenCalledWith( + 'did:plc:bob', + 'plugin:members', + 'Application revoked', + ) + expect(services.restoreAccount).toHaveBeenCalledWith('did:plc:bob', 'plugin:members') + }) + + it('reads the app name live', async () => { + let name = 'Before' + let context: PluginContext | undefined + await loadPlugins( + [ + plugin('reader', (ctx) => { + context = ctx + }), + ], + { + services, + logger, + app: { + get name() { + return name + }, + publicUrl: 'http://x', + }, + }, + ) + name = 'After' + expect(context?.app.name).toBe('After') + }) +}) diff --git a/apps/server/tests/plugins/import.test.ts b/apps/server/tests/plugins/import.test.ts deleted file mode 100644 index 313485a..0000000 --- a/apps/server/tests/plugins/import.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { parse } from 'yaml' -import { loadConfig } from '../../src/config.ts' -import { importPlugins } from '../../src/plugins/import.ts' - -const fixtures = fileURLToPath(new URL('../fixtures/plugins', import.meta.url)) -const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)) - -describe('importPlugins', () => { - it('loads a local plugin by path, validating and defaulting its options', async () => { - const plugins = await importPlugins( - [{ package: './good' }, { package: './good', options: { greeting: 'hi' } }], - fixtures, - ) - expect(plugins.map((plugin) => plugin.id)).toEqual(['good-hello', 'good-hi']) - }) - - it('stops with an error naming the package and option when options fail the schema', async () => { - await expect( - importPlugins([{ package: './good', options: { count: 'many' } }], fixtures), - ).rejects.toThrow(/"\.\/good".*count/) - await expect( - importPlugins([{ package: './good', options: { typo: true } }], fixtures), - ).rejects.toThrow(/"\.\/good"/) - }) - - it('stops with an error naming a package that cannot be found, and why', async () => { - await expect(importPlugins([{ package: '@scn-chat/plugin-nope' }], fixtures)).rejects.toThrow( - /Cannot load plugin package "@scn-chat\/plugin-nope".*Cannot find/, - ) - }) - - it('reports a broken package.json in a local plugin as a parse error', async () => { - const dir = mkdtempSync(join(tmpdir(), 'scn-plugin-')) - writeFileSync(join(dir, 'package.json'), '{ not json') - await expect(importPlugins([{ package: dir }], fixtures)).rejects.toThrow(/JSON/) - rmSync(dir, { recursive: true, force: true }) - }) - - it('stops when a package has no factory', async () => { - await expect(importPlugins([{ package: './noexport' }], fixtures)).rejects.toThrow( - /no default export factory/, - ) - }) - - it('resolves npm packages from the config directory', async () => { - const [plugin] = await importPlugins([{ package: '@scn-chat/plugin-titles' }], repoRoot) - expect(plugin?.id).toBe('titles') - }) -}) - -describe('config.example.yml', () => { - it('loads with every shipped plugin, with no secrets set', async () => { - const file = parse(readFileSync(`${repoRoot}/config.example.yml`, 'utf8')) - const config = loadConfig({ - env: { SECRET_KEY: Buffer.alloc(32, 1).toString('base64') }, - file, - configPath: `${repoRoot}/config.example.yml`, - }) - const plugins = await importPlugins(config.plugins, config.configDir) - expect(plugins.map((plugin) => plugin.id)).toEqual([ - 'openai-compatible-scn', - 'openai-compatible-cocore', - 'anthropic', - 'openai', - 'google', - 'openai-compatible-custom', - 'pdf-text', - 'titles', - 'web-search', - 'web-fetch', - ]) - }) -}) diff --git a/apps/server/tests/plugins/installed.test.ts b/apps/server/tests/plugins/installed.test.ts new file mode 100644 index 0000000..5f48c2f --- /dev/null +++ b/apps/server/tests/plugins/installed.test.ts @@ -0,0 +1,88 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import pino from 'pino' +import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { findInstalledPlugins, optionsJsonSchema, secretKeys } from '../../src/plugins/installed.ts' + +const fixtures = fileURLToPath(new URL('../fixtures/plugins', import.meta.url)) +const dirs: string[] = [] + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +/** A project root whose package.json depends on the named fixture packages, linked into node_modules. */ +function root(links: Record, extra: Record = {}) { + const dir = mkdtempSync(join(tmpdir(), 'scn-root-')) + dirs.push(dir) + mkdirSync(join(dir, 'node_modules')) + for (const [name, fixture] of Object.entries(links)) + symlinkSync(join(fixtures, fixture), join(dir, 'node_modules', name), 'dir') + for (const [name, contents] of Object.entries(extra)) { + mkdirSync(join(dir, 'node_modules', name)) + writeFileSync(join(dir, 'node_modules', name, 'package.json'), contents) + } + const dependencies = Object.fromEntries( + [...Object.keys(links), ...Object.keys(extra)].map((name) => [name, '*']), + ) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies })) + return dir +} + +function logged() { + const lines: Record[] = [] + const logger = pino({ level: 'info' }, { write: (line: string) => lines.push(JSON.parse(line)) }) + return { logger, lines } +} + +describe('findInstalledPlugins', () => { + it('lists dependencies with the plugin keyword, with their description, version, and schema', async () => { + const dir = root( + { 'good-plugin': 'good' }, + { 'not-a-plugin': JSON.stringify({ name: 'not-a-plugin', version: '1.0.0' }) }, + ) + const installed = await findInstalledPlugins(dir, logged().logger) + expect([...installed.keys()]).toEqual(['good-plugin']) + const good = installed.get('good-plugin') + expect(good).toMatchObject({ description: 'The good test plugin', version: '1.0.0' }) + expect(good?.factory({ greeting: 'hi' }).id).toBe('good-hi') + expect(optionsJsonSchema(good as never)).toMatchObject({ + properties: { greeting: { type: 'string', default: 'hello' } }, + }) + }) + + it('leaves out and logs a keyworded package without a factory', async () => { + const { logger, lines } = logged() + const installed = await findInstalledPlugins(root({ 'noexport-plugin': 'noexport' }), logger) + expect(installed.size).toBe(0) + expect(lines).toContainEqual(expect.objectContaining({ package: 'noexport-plugin' })) + }) + + it('finds the plugins this repository ships, and which can be added more than once', async () => { + const repo = fileURLToPath(new URL('../../../..', import.meta.url)) + const installed = await findInstalledPlugins(repo, logged().logger) + expect(installed.get('@scn-chat/plugin-web-search')?.multiple).toBe(false) + expect(installed.get('@scn-chat/plugin-openai-compatible')?.multiple).toBe(true) + }) + + it('marks every shipped plugin API key option secret, so it is stored encrypted and never returned', async () => { + const repo = fileURLToPath(new URL('../../../..', import.meta.url)) + const installed = await findInstalledPlugins(repo, logged().logger) + const keyed = [...installed.values()].filter((plugin) => + Object.keys(optionsJsonSchema(plugin).properties ?? {}).includes('apiKey'), + ) + expect(keyed.length).toBeGreaterThan(0) + for (const plugin of keyed) expect(secretKeys(plugin.optionsSchema)).toContain('apiKey') + }) +}) + +describe('secretKeys', () => { + it('names the top-level fields marked secret', () => { + const schema = z.object({ apiKey: z.string().meta({ secret: true }), name: z.string() }) + expect(secretKeys(schema)).toEqual(['apiKey']) + expect(secretKeys(undefined)).toEqual([]) + }) +}) diff --git a/apps/server/tests/plugins/registry.test.ts b/apps/server/tests/plugins/registry.test.ts index 1079fe4..a3a0856 100644 --- a/apps/server/tests/plugins/registry.test.ts +++ b/apps/server/tests/plugins/registry.test.ts @@ -15,22 +15,12 @@ const ingester = (id: string, accepts: string[], priority?: number): Ingester => }) describe('Registry', () => { - it('returns registered items by key', () => { + it('refuses a duplicate key, naming the plugin that already has it', () => { const registry = new Registry<{ id: string }>('provider', (item) => item.id) registry.register({ id: 'anthropic' }, 'plugin-a') - expect(registry.get('anthropic')).toEqual({ id: 'anthropic' }) - expect(registry.list()).toHaveLength(1) - }) - - it('refuses a duplicate key, naming both plugins', () => { - const registry = new Registry<{ id: string }>('provider', (item) => item.id) - registry.register({ id: 'anthropic' }, 'plugin-a') - expect(() => registry.register({ id: 'anthropic' }, 'plugin-b')).toThrow( - DuplicateRegistrationError, - ) - expect(() => registry.register({ id: 'anthropic' }, 'plugin-b')).toThrow( - /plugin-b.*anthropic.*plugin-a/, - ) + const again = () => registry.register({ id: 'anthropic' }, 'plugin-b') + expect(again).toThrow(DuplicateRegistrationError) + expect(again).toThrow('plugin-a') }) }) @@ -47,10 +37,4 @@ describe('IngesterRegistry.match', () => { registry.register(ingester('ocr', ['image/*']), 'a') expect(registry.match('image/png')?.id).toBe('ocr') }) - - it('returns nothing when no ingester accepts the type', () => { - const registry = new IngesterRegistry() - registry.register(ingester('pdf-text', ['application/pdf']), 'a') - expect(registry.match('text/csv')).toBeUndefined() - }) }) diff --git a/apps/server/tests/plugins/routes.test.ts b/apps/server/tests/plugins/routes.test.ts index f6449fa..e7f4ee9 100644 --- a/apps/server/tests/plugins/routes.test.ts +++ b/apps/server/tests/plugins/routes.test.ts @@ -89,7 +89,7 @@ async function setup() { db, logger, auth: authDeps(db), - plugins: { db, box, host }, + plugins: { db, box, host: () => host }, }) const cookie = sessionCookie(await app.request('/oauth/callback?code=a&state=b', loginCookie)) return { app, db, cookie } @@ -165,11 +165,6 @@ describe('plugin settings API', () => { expect((await switchTool(app, cookie, 'fetcher/tools/forced', false)).status).toBe(400) }) - it('returns 400 for a tool choice that is not a boolean', async () => { - const { app, cookie } = await setup() - expect((await switchTool(app, cookie, 'fetcher/tools/web_fetch', 'no')).status).toBe(400) - }) - it("keeps tool choices when the plugin's settings are reset", async () => { const { app, cookie } = await setup() await switchTool(app, cookie, 'search/tools/web_search', true) @@ -181,16 +176,6 @@ describe('plugin settings API', () => { expect(searchEntry?.tools[0]).toMatchObject({ name: 'web_search', enabled: true }) }) - it('refuses settings that are not a JSON object', async () => { - const { app, cookie } = await setup() - const put = await app.request('/api/plugins/search/settings', { - method: 'PUT', - headers: { cookie, origin: ORIGIN, 'content-type': 'application/json' }, - body: JSON.stringify(['kagi']), - }) - expect(put.status).toBe(400) - }) - it('saves settings and never returns secret values', async () => { const { app, cookie } = await setup() const put = await app.request('/api/plugins/search/settings', { @@ -234,7 +219,7 @@ describe('plugin settings API', () => { const broken = (await ( await app.request('/api/plugins/settings', { headers: { cookie } }) ).json()) as Settings - expect(broken.plugins[0]?.error).toMatch(/invalid/) + expect(broken.plugins[0]?.error).toBeTruthy() await app.request('/api/plugins/search/settings', { method: 'DELETE', headers: { cookie, origin: ORIGIN }, diff --git a/apps/server/tests/plugins/runtime.test.ts b/apps/server/tests/plugins/runtime.test.ts new file mode 100644 index 0000000..f8e3ea0 --- /dev/null +++ b/apps/server/tests/plugins/runtime.test.ts @@ -0,0 +1,140 @@ +import { definePlugin } from '@scn-chat/plugin-api' +import pino from 'pino' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { loadPlugins } from '../../src/plugins/host.ts' +import type { PluginInstance } from '../../src/plugins/instances.ts' +import { type PluginRuntime, RuntimeHolder, runtimeBuilder } from '../../src/plugins/runtime.ts' +import { installedFrom } from '../helpers/plugins.ts' + +const logger = pino({ level: 'silent' }) +const load = { services: {} as never, logger, app: { name: 'T', publicUrl: 'http://x' } } + +async function runtime(onClose?: () => void): Promise { + const host = await loadPlugins( + [ + definePlugin({ + id: 'p', + name: 'P', + apiVersion: 1, + setup: (ctx) => onClose && ctx.onClose(onClose), + }), + ], + load, + ) + return { host, catalog: {} as never, statuses: new Map() } +} + +const instance = (overrides: Partial): PluginInstance => ({ + id: 'i', + package: 'good', + enabled: true, + options: {}, + secrets: {}, + ...overrides, +}) + +describe('runtimeBuilder', () => { + const build = runtimeBuilder({ + installed: installedFrom({ + good: { + optionsSchema: z.object({ name: z.string().default('good') }).strict(), + factory: (options: { name: string }) => + definePlugin({ id: options.name, name: options.name, apiVersion: 1, setup: () => {} }), + }, + crashing: { + factory: () => + definePlugin({ + id: 'crashing', + name: 'Crashing', + apiVersion: 1, + setup: () => { + throw new Error('kaboom') + }, + }), + }, + }), + load, + catalog: () => ({}) as never, + logger, + }) + + it('loads enabled instances in order and records each status', async () => { + const built = await build([ + instance({ id: 'a', options: { name: 'first' } }), + instance({ id: 'b', enabled: false }), + instance({ id: 'c', options: { name: 'second' } }), + ]) + expect(built.host.plugins.map((p) => p.id)).toEqual(['first', 'second']) + expect(Object.fromEntries(built.statuses)).toEqual({ + a: { state: 'loaded', pluginId: 'first', name: 'first' }, + b: { state: 'disabled' }, + c: { state: 'loaded', pluginId: 'second', name: 'second' }, + }) + }) + + it('skips instances that are not installed, have invalid options, or fail setup, and says why', async () => { + const built = await build([ + instance({ id: 'gone', package: 'missing' }), + instance({ id: 'invalid', options: { name: 5 } }), + instance({ id: 'crash', package: 'crashing' }), + instance({ id: 'ok' }), + ]) + expect(built.host.plugins.map((p) => p.id)).toEqual(['good']) + const failed = (id: string) => + built.statuses.get(id) as { state: string; error: string; issues: unknown[] } + expect(failed('gone')).toMatchObject({ + state: 'failed', + error: expect.stringContaining('missing'), + }) + expect(failed('invalid')).toMatchObject({ state: 'failed', issues: [{ path: ['name'] }] }) + expect(failed('crash').error).toMatch(/kaboom/) + }) + + it('passes stored secrets to the plugin with its options', async () => { + const built = await build([instance({ options: {}, secrets: { name: 'from-secret' } })]) + expect(built.host.plugins.map((p) => p.id)).toEqual(['from-secret']) + }) +}) + +describe('RuntimeHolder', () => { + it('keeps a leased runtime open until its last lease is released', async () => { + const closed: string[] = [] + const first = await runtime(() => void closed.push('first')) + const holder = new RuntimeHolder(first, logger) + const lease = holder.acquire() + const second = holder.acquire() + holder.swap(await runtime()) + expect(holder.current()).not.toBe(first) + expect(lease.runtime).toBe(first) + lease.release() + lease.release() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(closed).toEqual([]) + second.release() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(closed).toEqual(['first']) + }) + + it('closes a replaced runtime at once when nothing holds it', async () => { + const closed: string[] = [] + const holder = new RuntimeHolder(await runtime(() => void closed.push('old')), logger) + holder.swap(await runtime()) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(closed).toEqual(['old']) + }) + + it('runs changes one at a time, even when one fails', async () => { + const holder = new RuntimeHolder(await runtime(), logger) + const order: string[] = [] + const slow = holder.exclusive(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + order.push('slow') + throw new Error('failed') + }) + const fast = holder.exclusive(async () => void order.push('fast')) + await expect(slow).rejects.toThrow('failed') + await fast + expect(order).toEqual(['slow', 'fast']) + }) +}) diff --git a/apps/server/tests/plugins/user-settings.test.ts b/apps/server/tests/plugins/user-settings.test.ts index 2d98868..07b8f63 100644 --- a/apps/server/tests/plugins/user-settings.test.ts +++ b/apps/server/tests/plugins/user-settings.test.ts @@ -7,7 +7,6 @@ import { clearUserSettings, InvalidPluginSettingsError, readUserSettings, - secretKeys, writeUserSettings, } from '../../src/plugins/user-settings.ts' import { SecretBox } from '../../src/secrets.ts' @@ -27,12 +26,6 @@ const plugin = definePlugin({ setup: () => {}, }) -describe('secretKeys', () => { - it('finds fields marked secret', () => { - expect(secretKeys(schema)).toEqual(['apiKey']) - }) -}) - describe.each(dialects)('plugin user settings on $name', ({ create }) => { const setup = async () => { const db = create() @@ -40,15 +33,6 @@ describe.each(dialects)('plugin user settings on $name', ({ create }) => { return db } - it('returns schema defaults when nothing is stored', async () => { - const db = await setup() - expect(await readUserSettings(db, box, did, plugin)).toEqual({ - engine: 'duckduckgo', - apiKey: '', - }) - await db.destroy() - }) - it('round-trips settings, storing secret fields encrypted', async () => { const db = await setup() await writeUserSettings(db, box, did, plugin, { engine: 'kagi', apiKey: 'sk-secret' }) diff --git a/apps/server/tests/plugins/user-tools.test.ts b/apps/server/tests/plugins/user-tools.test.ts index 5b34f77..e681b55 100644 --- a/apps/server/tests/plugins/user-tools.test.ts +++ b/apps/server/tests/plugins/user-tools.test.ts @@ -42,10 +42,4 @@ describe.each(dialects)('tool choices on $name', ({ create }) => { await writeToolChoice(db, 'did:plc:bob', 'web_search', true) expect(await readToolChoices(db, 'did:plc:alice')).toEqual(new Map([['web_search', false]])) }) - - it('returns no choices for a user who has made none', async () => { - const db = create() - await migrateToLatest(db) - expect(await readToolChoices(db, 'did:plc:alice')).toEqual(new Map()) - }) }) diff --git a/apps/server/tests/providers/catalog.test.ts b/apps/server/tests/providers/catalog.test.ts index 8a4a6ac..e68eeb6 100644 --- a/apps/server/tests/providers/catalog.test.ts +++ b/apps/server/tests/providers/catalog.test.ts @@ -1,30 +1,25 @@ import pino from 'pino' import { describe, expect, it } from 'vitest' -import { createRoles } from '../../src/auth/roles.ts' -import type { ModelConfig } from '../../src/config.ts' import { migrateToLatest } from '../../src/db/migrate.ts' import { Registry } from '../../src/plugins/registry.ts' -import { - ModelCatalog, - ModelUnavailable, - ProviderConfigError, - validateAdminModels, -} from '../../src/providers/catalog.ts' +import { type AdminModel, saveAdminModel } from '../../src/providers/admin-models.ts' +import { ModelCatalog, ModelUnavailable } from '../../src/providers/catalog.ts' import { saveCredential } from '../../src/providers/user-credentials.ts' import { SecretBox } from '../../src/secrets.ts' import { createSqliteDb } from '../helpers/db.ts' import { fakeProvider } from '../helpers/providers.ts' const caps = { vision: true, reasoning: true, tools: true } -const model = (overrides: Partial = {}): ModelConfig => ({ +const model = (overrides: Partial = {}): AdminModel => ({ provider: 'fake', id: 'big', name: 'Big', capabilities: caps, roles: ['user'], + default: false, ...overrides, }) -const roles = createRoles({ staff: ['did:plc:staff'] }) +const rolesOf = async (did: string) => (did === 'did:plc:staff' ? ['user', 'staff'] : ['user']) const guardedFetch = (async () => new Response()) as unknown as typeof fetch function registryWith(...providers: ReturnType['provider'][]) { @@ -33,43 +28,13 @@ function registryWith(...providers: ReturnType['provider'][ return registry } -describe('validateAdminModels', () => { - const registry = registryWith( - fakeProvider().provider, - fakeProvider({ id: 'keyless', hasAdminKey: false }).provider, - ) - - it('accepts valid models', () => { - expect(validateAdminModels([model()], registry, roles)).toHaveLength(1) - }) - - it.each([ - ['an unregistered provider', model({ provider: 'nope' }), /no plugin registered/], - ['a provider without an admin key', model({ provider: 'keyless' }), /no admin key/], - ['missing roles', model({ roles: undefined as never }), /must list the roles/], - ['empty roles', model({ roles: [] }), /must list the roles/], - ['an undefined role', model({ roles: ['vip'] }), /undefined role "vip"/], - ])('fails for %s', (_name, bad, message) => { - expect(() => validateAdminModels([bad], registry, roles)).toThrow(message) - }) - - it('fails when more than one model is marked default', () => { - expect(() => - validateAdminModels( - [model({ default: true }), model({ id: 'b', default: true })], - registry, - roles, - ), - ).toThrow(ProviderConfigError) - }) -}) - async function catalogWith( - adminModels: ModelConfig[], + adminModels: AdminModel[], ...providers: ReturnType['provider'][] ) { const db = createSqliteDb() await migrateToLatest(db) + for (const adminModel of adminModels) await saveAdminModel(db, adminModel, 'did:plc:admin') const box = new SecretBox(Buffer.alloc(32, 4)) const registry = registryWith(...providers) return { @@ -79,8 +44,7 @@ async function catalogWith( db, box, providers: registry, - adminModels, - roles, + rolesOf, guardedFetch, logger: pino({ level: 'silent' }), }), @@ -183,6 +147,28 @@ describe('ModelCatalog.listForUser', () => { }) const models = await catalog.listForUser('did:plc:alice') expect(models.map((m) => `${m.source}:${m.id}`)).toEqual(['admin:big', 'user:mine']) - expect(catalog.defaultModel()).toEqual({ provider: 'fake', id: 'big' }) + expect(await catalog.defaultModel()).toEqual({ provider: 'fake', id: 'big' }) + }) + + it('hides admin models whose provider is not loaded or has no admin key', async () => { + const { catalog } = await catalogWith( + [ + model({ provider: 'ghost', default: true }), + model({ provider: 'keyless', id: 'k' }), + model({ id: 'ok' }), + ], + fakeProvider().provider, + fakeProvider({ id: 'keyless', hasAdminKey: false }).provider, + ) + const models = await catalog.listForUser('did:plc:alice') + expect(models.map((m) => `${m.provider}/${m.id}`)).toEqual(['fake/ok']) + expect(await catalog.defaultModel()).toBeUndefined() + }) + + it('applies a model change on the next call', async () => { + const { db, catalog } = await catalogWith([], fakeProvider().provider) + expect(await catalog.listForUser('did:plc:alice')).toEqual([]) + await saveAdminModel(db, model(), 'did:plc:admin') + expect((await catalog.listForUser('did:plc:alice')).map((m) => m.id)).toEqual(['big']) }) }) diff --git a/apps/server/tests/providers/effort.test.ts b/apps/server/tests/providers/effort.test.ts index 538d310..2494e86 100644 --- a/apps/server/tests/providers/effort.test.ts +++ b/apps/server/tests/providers/effort.test.ts @@ -4,10 +4,6 @@ import { mapEffort } from '../../src/providers/effort.ts' describe('mapEffort', () => { it.each([ [undefined, 'provider-default'], - ['none', 'none'], - ['low', 'low'], - ['medium', 'medium'], - ['high', 'high'], ['max', 'xhigh'], ])('maps %s to %s', (effort, expected) => { expect(mapEffort(effort, { warn: vi.fn() } as never)).toBe(expected) diff --git a/apps/server/tests/providers/guarded-fetch.test.ts b/apps/server/tests/providers/guarded-fetch.test.ts index 1629425..b77256d 100644 --- a/apps/server/tests/providers/guarded-fetch.test.ts +++ b/apps/server/tests/providers/guarded-fetch.test.ts @@ -62,24 +62,16 @@ describe('isPrivateAddress', () => { }) describe('createGuardedFetch', () => { - it('refuses a private IP literal before connecting', async () => { - await expect(createGuardedFetch()(`http://127.0.0.1:${port}/ok`)).rejects.toBeInstanceOf( - PrivateNetworkError, - ) - }) - - it('refuses a bracketed IPv6 loopback literal', async () => { - await expect(createGuardedFetch()('http://[::1]:8080/')).rejects.toBeInstanceOf( + it.each([ + ['an IPv4', '127.0.0.1'], + ['a bracketed IPv6', '[::1]'], + ['an IPv4-mapped IPv6', '[::ffff:127.0.0.1]'], + ])('refuses %s private literal before connecting', async (_kind, host) => { + await expect(createGuardedFetch()(`http://${host}:${port}/ok`)).rejects.toBeInstanceOf( PrivateNetworkError, ) }) - it('refuses an IPv4-mapped IPv6 literal', async () => { - await expect( - createGuardedFetch()(`http://[::ffff:127.0.0.1]:${port}/ok`), - ).rejects.toBeInstanceOf(PrivateNetworkError) - }) - it('refuses a hostname that resolves to a private address', async () => { const error = await createGuardedFetch()(`http://localhost:${port}/ok`).catch( (err: unknown) => err, diff --git a/apps/server/tests/providers/routes.test.ts b/apps/server/tests/providers/routes.test.ts index 18bfc4d..03b3a21 100644 --- a/apps/server/tests/providers/routes.test.ts +++ b/apps/server/tests/providers/routes.test.ts @@ -1,9 +1,9 @@ import pino from 'pino' import { describe, expect, it, vi } from 'vitest' import { createApp } from '../../src/app.ts' -import { createRoles } from '../../src/auth/roles.ts' import { migrateToLatest } from '../../src/db/migrate.ts' import { Registry } from '../../src/plugins/registry.ts' +import { saveAdminModel } from '../../src/providers/admin-models.ts' import { ModelCatalog } from '../../src/providers/catalog.ts' import { SecretBox } from '../../src/secrets.ts' import { authDeps, loginCookie, ORIGIN, sessionCookie } from '../helpers/auth.ts' @@ -30,21 +30,23 @@ async function setup() { ) providers.register(fakeProvider({ id: 'adminonly', userKeys: false }).provider, 't') const guardedFetch = (async () => new Response()) as unknown as typeof fetch + await saveAdminModel( + db, + { + provider: 'fake', + id: 'big', + name: 'Big', + capabilities: caps, + roles: ['user'], + default: true, + }, + 'did:plc:admin', + ) const catalog = new ModelCatalog({ db, box, providers, - adminModels: [ - { - provider: 'fake', - id: 'big', - name: 'Big', - capabilities: caps, - roles: ['user'], - default: true, - }, - ], - roles: createRoles(), + rolesOf: async () => ['user'], guardedFetch, logger, }) @@ -53,7 +55,14 @@ async function setup() { db, logger, auth: authDeps(db), - providers: { db, box, catalog, providers, guardedFetch, logger }, + providers: { + db, + box, + catalog: () => catalog, + providers: () => providers, + guardedFetch, + logger, + }, }) const cookie = sessionCookie(await app.request('/oauth/callback?code=a&state=b', loginCookie)) const call = async (method: string, path: string, body?: object) => { @@ -110,17 +119,6 @@ describe('provider routes', () => { const { call } = await setup() const res = await call('POST', '/credentials', { providerId: 'fake', apiKey: 'k', models: [] }) expect(res.status).toBe(400) - expect(res.body.message).toMatch(/at least one model/) - }) - - it('rejects a credential body that is not JSON', async () => { - const { app, cookie } = await setup() - const res = await app.request('/api/credentials', { - method: 'POST', - headers: { cookie, origin: ORIGIN, 'content-type': 'application/json' }, - body: '{', - }) - expect(res.status).toBe(400) }) it('deletes a credential', async () => { diff --git a/apps/server/tests/providers/user-credentials.test.ts b/apps/server/tests/providers/user-credentials.test.ts index bdb3c3f..f8b2e10 100644 --- a/apps/server/tests/providers/user-credentials.test.ts +++ b/apps/server/tests/providers/user-credentials.test.ts @@ -62,16 +62,16 @@ describe.each(dialects)('user credentials on $name', ({ create }) => { const endpoint = { apiKey: 'k', baseUrl: 'https://x.example/v1', models: [] } await expect( saveCredential(db, box, did, fakeProvider().provider, { ...endpoint, slug: 'x' }), - ).rejects.toThrow(/user endpoints/) + ).rejects.toBeInstanceOf(CredentialInputError) const { provider } = fakeProvider({ userEndpoints: true }) await expect( saveCredential(db, box, did, provider, { ...endpoint, slug: 'Bad Slug' }), - ).rejects.toThrow(/slug/) + ).rejects.toBeInstanceOf(CredentialInputError) const saved = await saveCredential(db, box, did, provider, { ...endpoint, slug: 'mine' }) expect(saved.modelProvider).toBe('user:mine') await expect( saveCredential(db, box, did, provider, { ...endpoint, slug: 'mine' }), - ).rejects.toThrow(/already/) + ).rejects.toBeInstanceOf(CredentialInputError) await db.destroy() }) diff --git a/apps/server/tests/secrets.test.ts b/apps/server/tests/secrets.test.ts index fab9ef3..c5d8b6c 100644 --- a/apps/server/tests/secrets.test.ts +++ b/apps/server/tests/secrets.test.ts @@ -5,27 +5,18 @@ const keyA = Buffer.alloc(32, 1) const keyB = Buffer.alloc(32, 2) describe('SecretBox', () => { - it('round-trips a secret', () => { - const box = new SecretBox(keyA) - expect(box.decrypt(box.encrypt('sk-live-123'))).toBe('sk-live-123') - }) - - it('never stores the plaintext and uses a fresh IV each time', () => { + it('round-trips a secret without storing the plaintext, with a fresh IV each time', () => { const box = new SecretBox(keyA) const first = box.encrypt('sk-live-123') expect(first).not.toContain('sk-live-123') expect(box.encrypt('sk-live-123')).not.toBe(first) - }) - - it('writes the version and key ID into the value', () => { - expect(new SecretBox(keyA).encrypt('x')).toMatch(/^v1:[0-9a-f]{8}:/) + expect(box.decrypt(first)).toBe('sk-live-123') }) it('decrypts values from a retired key and encrypts new values with the current key', () => { const old = new SecretBox(keyA).encrypt('sk-old') const rotated = new SecretBox(keyB, [keyA]) expect(rotated.decrypt(old)).toBe('sk-old') - expect(rotated.encrypt('sk-new').split(':')[1]).toBe(rotated.encrypt('x').split(':')[1]) expect(new SecretBox(keyB).decrypt(rotated.encrypt('sk-new'))).toBe('sk-new') }) diff --git a/apps/server/tests/server.test.ts b/apps/server/tests/server.test.ts index 78a7c4f..16071a1 100644 --- a/apps/server/tests/server.test.ts +++ b/apps/server/tests/server.test.ts @@ -1,6 +1,9 @@ import { definePlugin } from '@scn-chat/plugin-api' import pino from 'pino' import { describe, expect, it, vi } from 'vitest' +import { migrateToLatest } from '../src/db/migrate.ts' +import { writeInstances } from '../src/plugins/instances.ts' +import { SecretBox } from '../src/secrets.ts' import { createServer } from '../src/server.ts' import { fakeIdentity, @@ -8,41 +11,45 @@ import { fakeSession, LOGIN_STATE, loginCookie, - ORIGIN, sessionCookie, } from './helpers/auth.ts' -import { testConfig } from './helpers/config.ts' +import { TEST_SECRET_KEY, testConfig } from './helpers/config.ts' import { createSqliteDb } from './helpers/db.ts' +import { installedFrom } from './helpers/plugins.ts' import { fakeProvider } from './helpers/providers.ts' -const caps = { vision: false, reasoning: false, tools: false } - -async function build() { +async function build(packages: string[]) { const { provider } = fakeProvider() + const db = createSqliteDb() + await migrateToLatest(db) + const box = new SecretBox(Buffer.from(TEST_SECRET_KEY, 'base64')) + await writeInstances( + db, + box, + packages.map((pkg, index) => ({ + id: `instance-${index}`, + package: pkg, + enabled: true, + options: {}, + secrets: {}, + })), + 'did:plc:admin', + ) const server = await createServer({ - config: testConfig(), - appConfig: { - plugins: [ - definePlugin({ - id: 'fake', - name: 'Fake', - apiVersion: 1, - setup: (ctx) => ctx.providers.register(provider), - }), - ], - models: [ - { - provider: 'fake', - id: 'm', - name: 'M', - capabilities: caps, - roles: ['user'], - default: true, - }, - ], - }, - db: createSqliteDb(), + config: testConfig({ ADMIN_DIDS: 'did:plc:alice' }), + db, logger: pino({ level: 'silent' }), + installed: installedFrom({ + 'fake-plugin': { + factory: () => + definePlugin({ + id: 'fake', + name: 'Fake', + apiVersion: 1, + setup: (ctx) => ctx.providers.register(provider), + }), + }, + }), oauth: fakeOAuth({ callback: vi.fn(async () => ({ session: fakeSession('did:plc:alice', 'atproto'), @@ -56,38 +63,19 @@ async function build() { } describe('createServer', () => { - it('wires every route: health, identity, OAuth, and the signed-in API', async () => { - const { app, close } = await build() - expect((await app.request('/api/health')).status).toBe(200) - expect((await app.request('/.well-known/did.json')).status).toBe(200) - expect((await app.request('/oauth-client-metadata.json')).status).toBe(200) + it('starts when a stored plugin is no longer installed, and reports it', async () => { + const { app, close } = await build(['gone-plugin', 'fake-plugin']) const cookie = sessionCookie(await app.request('/oauth/callback?code=a&state=b', loginCookie)) - const models = (await (await app.request('/api/models', { headers: { cookie } })).json()) as { - models: { id: string }[] + const body = (await ( + await app.request('/api/admin/plugins', { headers: { cookie } }) + ).json()) as { + instances: { package: string; status: string; error: string | null }[] } - expect(models.models.map((m) => m.id)).toEqual(['m']) - const created = await app.request('/api/conversations', { - method: 'POST', - headers: { cookie, origin: ORIGIN }, - }) - expect(created.status).toBe(201) + expect(body.instances.map((i) => [i.package, i.status])).toEqual([ + ['gone-plugin', 'failed'], + ['fake-plugin', 'loaded'], + ]) + expect(body.instances[0]?.error).toContain('gone-plugin') await close() }) - - it('fails to start when an admin model names an unregistered provider', async () => { - await expect( - createServer({ - config: testConfig(), - appConfig: { - plugins: [], - models: [{ provider: 'ghost', id: 'm', name: 'M', capabilities: caps, roles: ['user'] }], - }, - db: createSqliteDb(), - logger: pino({ level: 'silent' }), - oauth: fakeOAuth(), - identity: fakeIdentity(), - startBackgroundJobs: false, - }), - ).rejects.toThrow(/ghost/) - }) }) diff --git a/apps/server/tests/settings/store.test.ts b/apps/server/tests/settings/store.test.ts new file mode 100644 index 0000000..757a2eb --- /dev/null +++ b/apps/server/tests/settings/store.test.ts @@ -0,0 +1,52 @@ +import pino from 'pino' +import { describe, expect, it, vi } from 'vitest' +import { InvalidBody } from '../../src/body.ts' +import { migrateToLatest } from '../../src/db/migrate.ts' +import { SettingsStore, StoredSettingError } from '../../src/settings/store.ts' +import { dialects } from '../helpers/db.ts' + +const logger = pino({ level: 'silent' }) + +describe.each(dialects)('SettingsStore on $name', ({ create }) => { + async function setup() { + const db = create() + await migrateToLatest(db) + return { db, store: await SettingsStore.load(db, logger) } + } + + it('makes a valid write visible at once, stores it, and tells subscribers', async () => { + const { db, store } = await setup() + const listener = vi.fn() + store.subscribe('general', listener) + await store.set('general', { appName: 'Renamed' }, 'did:plc:admin') + expect(store.get('general')).toEqual({ appName: 'Renamed' }) + expect(listener).toHaveBeenCalledWith({ appName: 'Renamed' }) + expect((await SettingsStore.load(db, logger)).get('general').appName).toBe('Renamed') + }) + + it('refuses a write that fails the schema, with the field at fault', async () => { + const { store } = await setup() + const error = await store + .set('turns', { maxSteps: 0 }, 'did:plc:admin') + .catch((err: unknown) => err) + expect(error).toBeInstanceOf(InvalidBody) + expect((error as InvalidBody).issues[0]?.path).toEqual(['maxSteps']) + expect(store.get('turns').maxSteps).toBe(8) + }) + + it('stops loading when a stored value fails its schema, naming the key', async () => { + const { db } = await setup() + await db + .insertInto('app_setting') + .values({ + key: 'sessions', + value_json: '{"ttlDays":0}', + updated_at: new Date().toISOString(), + updated_by: 'did:plc:admin', + }) + .execute() + const loading = SettingsStore.load(db, logger) + await expect(loading).rejects.toBeInstanceOf(StoredSettingError) + await expect(loading).rejects.toThrow(/"sessions"/) + }) +}) diff --git a/apps/server/tests/sharing/sharing.test.ts b/apps/server/tests/sharing/sharing.test.ts index dadf027..473f0fc 100644 --- a/apps/server/tests/sharing/sharing.test.ts +++ b/apps/server/tests/sharing/sharing.test.ts @@ -156,13 +156,13 @@ describe('share settings', () => { expect(pds.policies.get(uri)?.readPolicy).toMatch(/publicPolicy/) }) - it('refuses a member list that is not a list', async () => { + it('refuses a member list that is not a list, or a member that looks like a DID but is not one', async () => { const { appFor, skey } = await setup() - const res = await (await appFor('did:plc:alice')).put(`/conversations/${skey}/sharing`, { - mode: 'people', - members: 'bob.test', - }) - expect(res.status).toBe(400) + const alice = await appFor('did:plc:alice') + const share = (members: unknown) => + alice.put(`/conversations/${skey}/sharing`, { mode: 'people', members }) + expect((await share('bob.test')).status).toBe(400) + expect((await share(['did:nope'])).status).toBe(400) }) it('returns 404 for the share settings of a conversation that does not exist', async () => { @@ -174,15 +174,6 @@ describe('share settings', () => { ).toBe(404) }) - it('refuses a member that looks like a DID but is not one', async () => { - const { appFor, skey } = await setup() - const res = await (await appFor('did:plc:alice')).put(`/conversations/${skey}/sharing`, { - mode: 'people', - members: ['did:nope'], - }) - expect(res.status).toBe(400) - }) - it('reflects changes made outside the app', async () => { const { appFor, skey, uri, pds } = await setup() pds.policies.set(uri, { diff --git a/apps/server/tests/storage/chat-service.test.ts b/apps/server/tests/storage/chat-service.test.ts index 847eb54..10f5ef2 100644 --- a/apps/server/tests/storage/chat-service.test.ts +++ b/apps/server/tests/storage/chat-service.test.ts @@ -124,16 +124,6 @@ describe.each(dialects)('ChatService on $name', ({ create }) => { await db.destroy() }) - it('stores preferences in the settings space', async () => { - const { chats, db } = await setup() - await chats.putPreferences({ customInstructions: 'Use metric units', generateTitles: false }) - expect(await chats.getPreferences()).toMatchObject({ - customInstructions: 'Use metric units', - generateTitles: false, - }) - await db.destroy() - }) - it('creates the settings space on the first write that needs it, and only then', async () => { const { chats, db } = await setup() const create = vi.spyOn(chats.store, 'createSpace') diff --git a/apps/server/tests/storage/local-record-store.test.ts b/apps/server/tests/storage/local-record-store.test.ts index 2f184e7..0431958 100644 --- a/apps/server/tests/storage/local-record-store.test.ts +++ b/apps/server/tests/storage/local-record-store.test.ts @@ -67,15 +67,6 @@ describe.each(dialects)('LocalRecordStore on $name', ({ create }) => { await db.destroy() }) - it('throws SpaceNotFound for a missing space', async () => { - const { store, db } = await setup() - const missing = spaceUri(did, nsid.conversation, '3bbbbbbbbbbbb') - await expect(store.putRecord(missing, nsid.info, 'self', info())).rejects.toBeInstanceOf( - SpaceNotFound, - ) - await db.destroy() - }) - it('computes the same CID an independent atproto CBOR encoder does', async () => { const { store, space, db } = await setup() const record = info('CID check') @@ -134,9 +125,12 @@ describe.each(dialects)('LocalRecordStore on $name', ({ create }) => { await db.destroy() }) - it('raises SpaceNotFound when reading a space that does not exist, as a PDS does', async () => { + it('raises SpaceNotFound for a space that does not exist, as a PDS does', async () => { const { store, db } = await setup() - const missing = 'at://did:plc:alice/space/network.sharedcomputer.chat.conversation/nope' + const missing = spaceUri(did, nsid.conversation, '3bbbbbbbbbbbb') + await expect(store.putRecord(missing, nsid.info, 'self', info())).rejects.toBeInstanceOf( + SpaceNotFound, + ) await expect(store.getRecord(missing, nsid.info, 'self')).rejects.toBeInstanceOf(SpaceNotFound) await expect(store.listRecords(missing, nsid.message)).rejects.toBeInstanceOf(SpaceNotFound) await expect(store.headRev(missing)).rejects.toBeInstanceOf(SpaceNotFound) diff --git a/apps/server/tests/storage/routes.test.ts b/apps/server/tests/storage/routes.test.ts index e73d1ae..7a6fef7 100644 --- a/apps/server/tests/storage/routes.test.ts +++ b/apps/server/tests/storage/routes.test.ts @@ -3,7 +3,7 @@ import { sql } from 'kysely' import { describe, expect, it, vi } from 'vitest' import { createApp } from '../../src/app.ts' import { buildScope } from '../../src/auth/scope.ts' -import { resolveSyncConfig } from '../../src/sync/scheduler.ts' +import { settingSchemas } from '../../src/settings/schemas.ts' import { authDeps, fakeOAuth, @@ -38,7 +38,7 @@ async function setup(storageMode: 'space' | 'local', syncOptions = {}) { services: h.services, events: h.events, engine: engine as never, - syncConfig: resolveSyncConfig(syncOptions), + sync: () => settingSchemas.sync.parse(syncOptions), logger: h.logger, }, }) diff --git a/apps/server/tests/storage/space-record-store.test.ts b/apps/server/tests/storage/space-record-store.test.ts index 4f2637c..467e734 100644 --- a/apps/server/tests/storage/space-record-store.test.ts +++ b/apps/server/tests/storage/space-record-store.test.ts @@ -91,6 +91,6 @@ describe('SpaceRecordStore', () => { const store = new SpaceRecordStore(did, async () => client as never) await expect( store.listOps(`at://${did}/space/${nsid.conversation}/3aaaaaaaaaaaa`), - ).rejects.toThrow(/without a commit/) + ).rejects.toThrow() }) }) diff --git a/apps/server/tests/sync/engine.test.ts b/apps/server/tests/sync/engine.test.ts index d85193f..0369615 100644 --- a/apps/server/tests/sync/engine.test.ts +++ b/apps/server/tests/sync/engine.test.ts @@ -24,6 +24,24 @@ async function writeAsClient( } describe('SyncEngine', () => { + it('does not sync a user without access', async () => { + let allowed = false + const h = await spacesHarness({ hasAccess: async () => allowed }) + const { skey } = await h.chats.createConversation() + await h.external.createRecord( + h.chats.conversationUri(skey), + nsid.message, + '3mmmmmmmmmmm1', + userMessage('hi') as never, + ) + const changed = collect(h.events, 'message:changed') + await h.engine.syncConversation(ALICE, skey) + expect(changed).toEqual([]) + allowed = true + await h.engine.syncConversation(ALICE, skey) + expect(changed).toHaveLength(1) + }) + it('turns a direct write noticed through the index into a live message event', async () => { const h = await spacesHarness() const { skey } = await h.chats.createConversation() diff --git a/apps/server/tests/sync/identity.test.ts b/apps/server/tests/sync/identity.test.ts index 05883be..0448d8d 100644 --- a/apps/server/tests/sync/identity.test.ts +++ b/apps/server/tests/sync/identity.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { appDid, didDocument, serviceId } from '../../src/sync/identity.ts' +import { appDid, didDocument } from '../../src/sync/identity.ts' describe('app identity', () => { it('derives a did:web from the public URL, encoding a port', () => { @@ -7,10 +7,6 @@ describe('app identity', () => { expect(appDid('http://localhost:3000')).toBe('did:web:localhost%3A3000') }) - it('names the service with the #scn_chat fragment', () => { - expect(serviceId('https://chat.example.com')).toBe('did:web:chat.example.com#scn_chat') - }) - it('publishes one AtprotoSpaceService entry pointing at the public URL', () => { expect(didDocument('https://chat.example.com')).toEqual({ '@context': ['https://www.w3.org/ns/did/v1'], diff --git a/apps/server/tests/sync/scheduler.test.ts b/apps/server/tests/sync/scheduler.test.ts index 630318f..84c879a 100644 --- a/apps/server/tests/sync/scheduler.test.ts +++ b/apps/server/tests/sync/scheduler.test.ts @@ -1,9 +1,15 @@ -import { describe, expect, it } from 'vitest' +import pino from 'pino' +import { afterEach, describe, expect, it, vi } from 'vitest' import { recordLogin, setBackgroundSync } from '../../src/auth/accounts.ts' import { migrateToLatest } from '../../src/db/migrate.ts' -import { eligibleUsers, resolveSyncConfig } from '../../src/sync/scheduler.ts' +import { settingSchemas } from '../../src/settings/schemas.ts' +import type { SyncEngine } from '../../src/sync/engine.ts' +import { eligibleUsers, type SyncSettings, startSyncScheduler } from '../../src/sync/scheduler.ts' import { createSqliteDb } from '../helpers/db.ts' +const defaults = () => settingSchemas.sync.parse({}) +const everyone = async () => true + async function accounts() { const db = createSqliteDb() await migrateToLatest(db) @@ -12,6 +18,7 @@ async function accounts() { await recordLogin(db, { ...base, did: 'did:plc:optedout', spacesAllowed: true }) await setBackgroundSync(db, 'did:plc:optedout', false) await recordLogin(db, { ...base, did: 'did:plc:local', spacesAllowed: false }) + await recordLogin(db, { ...base, did: 'did:plc:viewer', spacesAllowed: true, viewer: true }) await recordLogin( db, { ...base, did: 'did:plc:idle', spacesAllowed: true }, @@ -20,27 +27,55 @@ async function accounts() { return db } -describe('resolveSyncConfig', () => { - it('fills in the defaults', () => { - expect(resolveSyncConfig()).toEqual({ - safetyNet: { enabled: true, intervalMinutes: 15, activeWithinHours: 24 }, - discovery: { intervalMinutes: 60, activeWithinDays: 30 }, - allowUserOptOut: true, - }) - }) -}) +afterEach(() => vi.useRealTimers()) describe('eligibleUsers', () => { const since = new Date(Date.now() - 86_400_000) - it('includes active spaces users and skips opted-out, local, and idle ones', async () => { + it('includes active spaces users and skips opted-out, local, viewer, and idle ones', async () => { const db = await accounts() - expect(await eligibleUsers(db, resolveSyncConfig(), since)).toEqual(['did:plc:active']) + expect(await eligibleUsers(db, defaults(), since, everyone)).toEqual(['did:plc:active']) }) it('includes users who opted out when the admin does not allow opting out', async () => { const db = await accounts() - const users = await eligibleUsers(db, resolveSyncConfig({ allowUserOptOut: false }), since) + const config = settingSchemas.sync.parse({ allowUserOptOut: false }) + const users = await eligibleUsers(db, config, since, everyone) expect(users.sort()).toEqual(['did:plc:active', 'did:plc:optedout']) }) + + it('skips users without access', async () => { + const db = await accounts() + expect(await eligibleUsers(db, defaults(), since, async () => false)).toEqual([]) + }) +}) + +describe('startSyncScheduler', () => { + it('restarts its timers with new intervals when the sync settings change', async () => { + vi.useFakeTimers() + const db = await accounts() + let sync: SyncSettings = settingSchemas.sync.parse({ discovery: { intervalMinutes: 60 } }) + let changed = () => {} + const engine = { + renewRegistrations: vi.fn(async () => {}), + safetyNet: vi.fn(async () => {}), + discover: vi.fn(async () => {}), + } + const stop = startSyncScheduler({ + engine: engine as unknown as SyncEngine, + db, + sync: () => sync, + onSyncChange: (listener) => { + changed = listener + return () => {} + }, + hasAccess: everyone, + logger: pino({ level: 'silent' }), + }) + sync = settingSchemas.sync.parse({ discovery: { intervalMinutes: 1 } }) + changed() + await vi.advanceTimersByTimeAsync(61_000) + expect(engine.discover).toHaveBeenCalledWith('did:plc:active') + stop() + }) }) diff --git a/apps/server/tests/turns/accumulator.test.ts b/apps/server/tests/turns/accumulator.test.ts index 4054ad9..99b308f 100644 --- a/apps/server/tests/turns/accumulator.test.ts +++ b/apps/server/tests/turns/accumulator.test.ts @@ -107,18 +107,6 @@ describe('PartAccumulator', () => { expect(acc.lexiconUsage()).toEqual({ inputTokens: 3 }) }) - it('adds a cited source as a source part', () => { - const acc = new PartAccumulator() - acc.cite({ url: 'https://example.com/a', title: 'Example' }) - expect(acc.parts).toEqual([ - { - $type: 'network.sharedcomputer.chat.defs#sourcePart', - url: 'https://example.com/a', - title: 'Example', - }, - ]) - }) - it('ignores cited URLs that are not http or https', () => { const acc = new PartAccumulator() acc.cite({ url: 'javascript:alert(1)' }) diff --git a/apps/server/tests/turns/prompt.test.ts b/apps/server/tests/turns/prompt.test.ts index 7c37580..08277bb 100644 --- a/apps/server/tests/turns/prompt.test.ts +++ b/apps/server/tests/turns/prompt.test.ts @@ -71,10 +71,6 @@ describe('buildInstructions', () => { ), ).toBe('Base\n\nMetric units\n\nBe brief') }) - - it('returns an empty string when there is nothing to send', () => { - expect(buildInstructions('', null, null)).toBe('') - }) }) describe('fillBasePrompt', () => { @@ -99,14 +95,6 @@ describe('fillBasePrompt', () => { describe('timeZoneOf', () => { const logger = pino({ level: 'silent' }) - it('returns the zone from preferences', () => { - expect(timeZoneOf({ timezone: 'Europe/Paris' }, logger)).toBe('Europe/Paris') - }) - - it('falls back to UTC when preferences have no zone', () => { - expect(timeZoneOf(null, logger)).toBe('UTC') - }) - it('falls back to UTC and warns for a zone that does not exist', () => { const warn = vi.spyOn(logger, 'warn') expect(timeZoneOf({ timezone: 'Mars/Olympus' }, logger)).toBe('UTC') @@ -245,8 +233,8 @@ describe('toModelMessages', () => { }) const parts = user?.content as { type: string; text?: string; mediaType?: string }[] expect(parts[0]).toMatchObject({ type: 'file', mediaType: 'image/png' }) - expect(parts[1]?.text).toBe('# report.pdf\n\nPDF body') - expect(parts[2]?.text).toMatch(/scan\.pdf.*could not be read/) + expect(parts[1]?.text).toMatch(/report\.pdf[\s\S]*PDF body/) + expect(parts[2]?.text).toContain('scan.pdf') }) it('refuses images for a model without vision', async () => { @@ -281,7 +269,7 @@ describe('toModelMessages', () => { readBlob: noBlobs, isUntrusted: trusted, }), - ).rejects.toThrow(/c9 has no tool call/) + ).rejects.toThrow(/c9/) }) it('throws on tool input that is not JSON', async () => { @@ -305,7 +293,7 @@ describe('toModelMessages', () => { ).rejects.toBeInstanceOf(SyntaxError) }) - it('fails with a readable error on encrypted content', async () => { + it('refuses encrypted content', async () => { const branch = [msg('a', { role: 'user', content: { $type: d('encryptedContent') } })] await expect( toModelMessages(branch, { @@ -314,7 +302,7 @@ describe('toModelMessages', () => { readBlob: noBlobs, isUntrusted: trusted, }), - ).rejects.toThrow(/encrypted/) + ).rejects.toBeInstanceOf(TurnInputError) }) it('leaves out a tool call that never got a result, as in a reply cancelled mid-call', async () => { diff --git a/apps/server/tests/turns/runner.test.ts b/apps/server/tests/turns/runner.test.ts index c6bf414..2554976 100644 --- a/apps/server/tests/turns/runner.test.ts +++ b/apps/server/tests/turns/runner.test.ts @@ -304,7 +304,7 @@ describe('TurnRunner.start', () => { String(p.$type).endsWith('toolResultPart'), ) expect(result?.outputBlob).toMatchObject({ $type: 'blob', mimeType: 'text/plain' }) - expect(String(result?.output)).toMatch(/stored as a blob/) + expect(String(result?.output).length).toBeLessThan(1000) expect(h.stored.get(`bafkrei0fake`)?.length).toBeGreaterThan(900_000) }) @@ -496,27 +496,9 @@ describe('TurnRunner.start', () => { expect(partsOf(reply)).toBe('') }) - it('fails the turn on encrypted messages', async () => { - const h = await turnsHarness() - const { skey } = await h.chats.createConversation() - await h.chats.createMessage(skey, '3uuuuuuuuuuu1', { - ...userMessage('x', { generation: {} }), - content: { - $type: `${nsid.defs}#encryptedContent`, - scheme: 'test', - keyId: 'k', - nonce: { $bytes: 'AAAAAAAAAAAAAAAA' }, - ciphertext: { $bytes: 'AAAA' }, - }, - } as never) - await h.runner.start(ALICE, skey, '3uuuuuuuuuuu1') - await h.runner.idle() - const reply = (await h.messages(skey)).get('3uuuuuuuuuuu1.r0') - expect(reply).toMatchObject({ status: 'error', error: expect.stringContaining('encrypted') }) - }) - it('fails the turn when it asks for tools on a model that cannot use them', async () => { const h = await turnsHarness({ + host: await hostWith([tool('web_search')]), adminModels: [ { id: 'plain', @@ -530,10 +512,7 @@ describe('TurnRunner.start', () => { await h.runner.start(ALICE, skey, '3uuuuuuuuuuu1') await h.runner.idle() const reply = (await h.messages(skey)).get('3uuuuuuuuuuu1.r0') - expect(reply).toMatchObject({ - status: 'error', - error: expect.stringContaining('cannot use tools'), - }) + expect(reply?.status).toBe('error') }) it('fails the turn when it asks for a tool that is not installed', async () => { @@ -595,9 +574,7 @@ describe('TurnRunner.start', () => { it('throws for an account that does not exist', async () => { const h = await turnsHarness() const { skey } = await h.chats.createConversation() - await expect(h.runner.start('did:plc:nobody', skey, '3uuuuuuuuuuu1')).rejects.toThrow( - /No account/, - ) + await expect(h.runner.start('did:plc:nobody', skey, '3uuuuuuuuuuu1')).rejects.toThrow() }) it('publishes stream events that a late subscriber replays before following live', async () => { @@ -771,9 +748,10 @@ describe('TurnRunner.recover', () => { describe('safeErrorMessage', () => { it('removes API keys and bearer tokens', () => { - expect(safeErrorMessage(new Error('bad key sk-proj-abc12345678 and Bearer abc.def.ghi'))).toBe( - 'bad key [redacted] and Bearer [redacted]', + const message = safeErrorMessage( + new Error('bad key sk-proj-abc12345678 and Bearer abc.def.ghi'), ) + expect(message).not.toMatch(/sk-proj|abc\.def\.ghi/) }) }) @@ -961,21 +939,6 @@ describe('TurnRunner tools', () => { }) }) - it("gives tools the runner's tool fetch", async () => { - const script = sequenceModel([toolCall('web'), textReply('Done.')]) - let seen: typeof fetch | undefined - const web = tool('web', { - defaultEnabled: true, - run: async (_input, context) => { - seen = context.fetch - return 'page' - }, - }) - const h = await turnsHarness({ host: await hostWith([web]), model: () => script.model }) - await turn(h) - expect(seen).toBe(fetch) - }) - it('keeps a turn cache private to each tool, and empty in the next turn', async () => { const script = sequenceModel([ toolCall('a', 'c1'), @@ -1007,3 +970,98 @@ describe('TurnRunner tools', () => { expect(seen).toEqual({ a: [1, 2, 1], b: [1] }) }) }) + +describe('TurnRunner and the admin area', () => { + it('does not start a turn for a user without access', async () => { + const h = await turnsHarness({ hasAccess: async () => false }) + const { skey } = await h.chats.createConversation() + await sendUser(h, skey, '3uuuuuuuuuuu1') + expect(await h.runner.start(ALICE, skey, '3uuuuuuuuuuu1')).toEqual({ status: 'skipped' }) + expect((await h.messages(skey)).size).toBe(1) + }) + + it('reads a new system prompt and app name at the next turn', async () => { + const script = sequenceModel([textReply('ok')]) + let name = 'First Name' + const h = await turnsHarness({ + model: () => script.model, + systemPrompt: 'First prompt in {{appName}}.', + appName: () => name, + }) + await turn(h) + h.settings.systemPrompt = 'Second prompt in {{appName}}.' + name = 'Second Name' + await turn(h, {}, undefined, '3uuuuuuuuuuu2') + const instructions = script.prompts.map((prompt) => + JSON.stringify((prompt as { role: string }[]).find((m) => m.role === 'system')), + ) + expect(instructions[0]).toContain('First prompt in First Name.') + expect(instructions[1]).toContain('Second prompt in Second Name.') + }) + + it("gives tools the user's roles", async () => { + const script = sequenceModel([toolCall('whoami'), textReply('done')]) + let seen: string[] = [] + const host = await hostWith([ + tool('whoami', { + defaultEnabled: true, + run: async (_input, context) => { + seen = context.roles + return 'ok' + }, + }), + ]) + const h = await turnsHarness({ + host, + model: () => script.model, + rolesOf: async () => ['user', 'member'], + }) + await turn(h) + expect(seen).toEqual(['user', 'member']) + }) + + it('finishes a turn with the plugins it started with when the plugins change mid-turn', async () => { + const script = sequenceModel([toolCall('calc'), textReply('done')]) + const calls: string[] = [] + const closed: string[] = [] + const host = await loadPlugins( + [ + definePlugin({ + id: 'old', + name: 'Old', + apiVersion: 1, + setup: (ctx) => { + ctx.tools.register( + tool('calc', { + defaultEnabled: true, + run: async () => { + calls.push('old calc') + h.plugins.swap({ ...h.plugins.current(), host: replacement }) + return 'ok' + }, + }), + ) + ctx.onClose(() => void closed.push('old')) + }, + }), + ], + { + services: {} as never, + logger: pino({ level: 'silent' }), + app: { name: 'T', publicUrl: 'x' }, + }, + ) + const replacement = await loadPlugins([], { + services: {} as never, + logger: pino({ level: 'silent' }), + app: { name: 'T', publicUrl: 'x' }, + }) + const h = await turnsHarness({ host, model: () => script.model }) + const { reply } = await turn(h) + expect(calls).toEqual(['old calc']) + expect(reply?.status).toBe('complete') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(closed).toEqual(['old']) + expect(h.plugins.current().host).toBe(replacement) + }) +}) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c5bc445..c3020bc 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,6 +3,7 @@ import { Redirect, Route, Switch, useLocation } from 'wouter' import { ApiError, api, onUnauthorized, read } from './api.ts' import { messageOf } from './components/useAction.ts' import { syncTimeZone } from './lib/time-zone.ts' +import { AdminPage } from './pages/AdminPage.tsx' import { ChatPage } from './pages/ChatPage.tsx' import { LoginPage } from './pages/LoginPage.tsx' import { SettingsPage } from './pages/SettingsPage.tsx' @@ -58,6 +59,9 @@ function SignedIn({ me }: { me: Me }) { + + {me.admin ? : } + @@ -83,11 +87,34 @@ export function App() { )} - {signedIn ? : } + + {!signedIn ? ( + + ) : session.me.access === 'viewer' ? ( + + ) : ( + + )} + ) } +/** UI fo users signed in only to view shared chats. */ +function ViewerOnly({ message }: { message: string | null }) { + const signOut = () => + read(api.auth.logout.$post()) + .catch((err: unknown) => console.error('Could not sign out', err)) + .finally(() => location.assign('/login')) + return ( + + + + ) +} + function SignedOut() { const [location] = useLocation() if (location !== '/login') return diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 68e685d..c3c2bda 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,4 +1,5 @@ import type { + AdminApi, AuthApi, BlobsApi, PluginsApi, @@ -9,7 +10,7 @@ import type { } from '@scn-chat/server/api-types' import { type ClientResponse, hc } from 'hono/client' import type { SuccessStatusCode } from 'hono/utils/http-status' -import { errorMessage } from './lib/response.ts' +import { errorDetails, type Issue } from './lib/response.ts' /** Clients for calling the hono server's /api routes. **/ export const api = { @@ -20,6 +21,7 @@ export const api = { plugins: hc('/api/plugins'), blobs: hc('/api'), sharing: hc('/api'), + admin: hc('/api/admin'), } /** Build the request options for a JSON body with api calls. */ @@ -37,11 +39,14 @@ export function onUnauthorized(listener: () => void): () => void { export class ApiError extends Error { readonly status: number + /** Problems with individual form fields. */ + readonly issues: Issue[] - constructor(status: number, message: string) { + constructor(status: number, message: string, issues: Issue[] = []) { super(message) this.name = 'ApiError' this.status = status + this.issues = issues } } @@ -60,9 +65,9 @@ export async function read>( const res = await response if (res.status === 401) for (const listener of unauthorized) listener() if (!res.ok) { - const message = await errorMessage(res) + const { message, issues } = await errorDetails(res) if (res.status !== 401) console.error(`Request failed: ${res.status} ${res.url}`, message) - throw new ApiError(res.status, message) + throw new ApiError(res.status, message, issues) } return (await res.json()) as SuccessBody } diff --git a/apps/web/src/components/SchemaFields.tsx b/apps/web/src/components/SchemaFields.tsx index 6995243..61ed8b1 100644 --- a/apps/web/src/components/SchemaFields.tsx +++ b/apps/web/src/components/SchemaFields.tsx @@ -1,28 +1,70 @@ +import { useState } from 'react' import { applyRule, type FieldRule } from '../lib/field-rules.ts' +import type { Issue } from '../lib/response.ts' -type Property = { +export type Property = { type?: string enum?: unknown[] description?: string title?: string rule?: FieldRule + default?: unknown + multiline?: boolean + items?: Property + properties?: Record } -type Schema = { properties?: Record } +export type Schema = { properties?: Record } export type SchemaFieldsProps = { schema: Schema values: Record - secretFields: string[] - secretsSet: string[] + secretFields?: string[] + secretsSet?: string[] + /** Offer to clear stored secrets, for forms that support it. */ + cleared?: string[] + onClear?: (key: string, clear: boolean) => void + /** Problems the server found, by field path. */ + issues?: Issue[] onChange: (key: string, value: unknown) => void } -/** Inputs for a plugin's settings, generated from its JSON schema. */ +/** A textarea for a list of strings, one per line. */ +function LinesInput({ + value, + disabled, + onChange, +}: { + value: string[] + disabled: boolean + onChange: (value: string[]) => void +}) { + const [text, setText] = useState(value.join('\n')) + return ( +