From 00e93fb72549352cd799ba2f456c868cd610da32 Mon Sep 17 00:00:00 2001 From: Scott Hadfield Date: Tue, 6 Oct 2026 10:51:17 -0700 Subject: [PATCH] Add API key interface and a cron route. Improve the role membership hooks. --- CLAUDE.md | 2 +- apps/server/src/admin/routes.ts | 35 ++ apps/server/src/app.ts | 17 + apps/server/src/auth/access.ts | 3 +- apps/server/src/auth/api-keys.ts | 92 +++ apps/server/src/auth/role-store.ts | 65 +++ apps/server/src/auth/roles.ts | 62 +- apps/server/src/auth/routes.ts | 19 +- apps/server/src/cron.ts | 79 +++ .../db/migrations/0010_api_keys_and_cron.ts | 21 + apps/server/src/db/migrations/index.ts | 2 + apps/server/src/db/schema.ts | 10 + apps/server/src/plugins/hooks.ts | 23 +- apps/server/src/plugins/host.ts | 20 +- apps/server/src/plugins/runtime.ts | 7 +- apps/server/src/server.ts | 41 +- apps/server/tests/auth/access.test.ts | 39 +- apps/server/tests/auth/api-keys.test.ts | 77 +++ apps/server/tests/auth/roles.test.ts | 94 ++- apps/server/tests/cron.test.ts | 83 +++ apps/server/tests/helpers/settings.ts | 2 +- apps/server/tests/plugins/hooks.test.ts | 13 +- apps/server/tests/plugins/host.test.ts | 21 +- apps/server/tests/plugins/runtime.test.ts | 5 + apps/web/src/pages/AdminPage.tsx | 549 +++++++++++++----- apps/web/tests/pages/AdminPage.test.tsx | 148 ++++- docs/deployment.md | 7 +- docs/plugins.md | 19 +- packages/plugin-api/src/index.ts | 27 +- packages/plugin-api/src/testing.ts | 38 +- plugins/scn-member-registry/README.md | 4 +- plugins/scn-member-registry/src/index.ts | 54 +- .../scn-member-registry/tests/index.test.ts | 98 ++-- specs/README.md | 2 + specs/admin-plugins.md | 5 +- specs/admin.md | 67 +-- specs/api-keys.md | 81 +++ specs/cron.md | 80 +++ specs/plugins.md | 7 +- specs/scn-member-registry.md | 127 ++-- 40 files changed, 1591 insertions(+), 554 deletions(-) create mode 100644 apps/server/src/auth/api-keys.ts create mode 100644 apps/server/src/cron.ts create mode 100644 apps/server/src/db/migrations/0010_api_keys_and_cron.ts create mode 100644 apps/server/tests/auth/api-keys.test.ts create mode 100644 apps/server/tests/cron.test.ts create mode 100644 specs/api-keys.md create mode 100644 specs/cron.md diff --git a/CLAUDE.md b/CLAUDE.md index a86c0a1..b533b94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ These are settled. Raise a concern before working against any of them. - Every record belongs to the user, including the assistant's replies. The running app never writes records under its own DID. The one exception is publishing the lexicons, which the maintainers do from a lexicon authority account, outside the app. - One space per conversation, of type `network.sharedcomputer.chat.conversation`. Sharing a chat means changing that space's read policy. Fallback users cannot share or view shared chats. -- There is no public HTTP API, apart from XRPC procedures defined in our own lexicons, currently `requestSync`. The user's PDS is the API: a client can hold a whole conversation by writing records directly. The conventions for that are in `lexicons/README.md`, and the server must honor them exactly. +- There is no public HTTP API, apart from XRPC procedures defined in our own lexicons, currently `requestSync`, and routes opened to admin-issued API keys, currently cron. The user's PDS is the API: a client can hold a whole conversation by writing records directly. The conventions for that are in `lexicons/README.md`, and the server must honor them exactly. - Users whose PDS does not support spaces fall back to local storage in SQLite or Postgres. It sits behind the same PDS-shaped record interface as the spaces backend, so it can be deleted once PDSs support spaces. Local rows store the exact lexicon record JSON under the same space and record keys, so migrating a user to their PDS is a replay. - Each conversation has an entry record in the user's settings space. Together the entries are the user's chat index, which is how the server and browser find conversations and notice changes with one call per user. - 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. diff --git a/apps/server/src/admin/routes.ts b/apps/server/src/admin/routes.ts index 1fee0d9..7bf7e09 100644 --- a/apps/server/src/admin/routes.ts +++ b/apps/server/src/admin/routes.ts @@ -4,6 +4,7 @@ import { sql } from 'kysely' import { z } from 'zod' import { type Access, CannotSuspendAdmin } from '../auth/access.ts' import { getAccount } from '../auth/accounts.ts' +import { issueApiKey, listApiKeys, revokeApiKey } from '../auth/api-keys.ts' import type { IdentityResolver } from '../auth/identity.ts' import { addInvite, listInvites, removeInvite } from '../auth/invites.ts' import { @@ -19,6 +20,7 @@ import { import { ADMIN_ROLE, IMPLICIT_ROLE } from '../auth/roles.ts' import { requireAdmin, signedInUser } from '../auth/routes.ts' import { InvalidBody, jsonBody } from '../body.ts' +import type { Cron } from '../cron.ts' import type { AppEnv } from '../env.ts' import type { SettingsStore } from '../settings/store.ts' import { type PluginAdminRoutesDeps, pluginAdminRoutes } from './plugin-routes.ts' @@ -26,6 +28,7 @@ import { settingsAdminRoutes } from './settings-routes.ts' export type AdminRoutesDeps = PluginAdminRoutesDeps & { access: Access + cron: Cron settings: SettingsStore identity: IdentityResolver } @@ -66,6 +69,9 @@ async function roleUses(deps: AdminRoutesDeps, name: string): Promise if ((JSON.parse(model.roles_json) as string[]).includes(name)) uses.push(`model ${model.provider}/${model.model_id}`) } + for (const key of await listApiKeys(deps.db)) { + if (key.roles.includes(name)) uses.push(`API key ${key.label}`) + } return uses } @@ -288,11 +294,40 @@ function peopleRoutes(deps: AdminRoutesDeps) { }) } +/** Issuing and revoking API keys. */ +function apiKeyRoutes(deps: AdminRoutesDeps) { + return new Hono() + .get('/api-keys', async (c) => c.json({ keys: await listApiKeys(deps.db) })) + .post('/api-keys', async (c) => { + const body = await jsonBody( + c, + z.object({ label: z.string().trim().min(1), roles: z.array(z.string()).min(1) }), + ) + const known = await deps.roles.names() + const unknown = body.roles.filter((role) => !known.has(role)) + if (unknown.length) + throw new InvalidBody(`No such role: ${unknown.join(', ')}`, [ + { path: ['roles'], message: `No such role: ${unknown.join(', ')}` }, + ]) + const admin = signedInUser(c).did + const issued = await issueApiKey(deps.db, body, admin) + deps.logger.info({ admin, key: body.label, roles: body.roles }, 'API key issued') + return c.json(issued, 201) + }) + .delete('/api-keys/:id', async (c) => { + if (!(await revokeApiKey(deps.db, c.req.param('id')))) return c.json(notFound('API key'), 404) + deps.logger.info({ admin: signedInUser(c).did, id: c.req.param('id') }, 'API key revoked') + return c.json({ ok: true }) + }) +} + /** Everything in the admin area, for admins only. */ export function adminRoutes(deps: AdminRoutesDeps) { return new Hono() .use(requireAdmin) .route('/', peopleRoutes(deps)) + .route('/', apiKeyRoutes(deps)) + .get('/cron', async (c) => c.json({ lastRun: await deps.cron.lastRun() })) .route('/', pluginAdminRoutes(deps)) .route('/', settingsAdminRoutes(deps)) } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 89404ec..f8cd718 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -5,7 +5,9 @@ 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 { requireApiKey } from './auth/api-keys.ts' import { SessionExpired } from './auth/pds.ts' +import { ADMIN_ROLE } from './auth/roles.ts' import { type AuthDeps, accessGate, @@ -17,6 +19,7 @@ import { import { type BlobRoutesDeps, blobRoutes } from './blobs/routes.ts' import { InvalidBody } from './body.ts' import type { Config } from './config.ts' +import { type Cron, CronRunning } from './cron.ts' import type { Db } from './db/index.ts' import type { AppEnv } from './env.ts' import { pdsFailure } from './lex-errors.ts' @@ -57,6 +60,7 @@ export type AppDeps = { sharing?: SharingService sync?: SyncRoutesDeps admin?: AdminRoutesDeps + cron?: Cron } export function createApp(deps: AppDeps) { @@ -94,6 +98,19 @@ export function createApp(deps: AppDeps) { } }) + // Key routes come before the routers below, whose middleware wants a session. + const cron = deps.cron + if (cron) + api.post('/cron', requireApiKey({ db, logger }, ADMIN_ROLE), async (c) => { + try { + return c.json(await cron.run()) + } catch (err) { + if (err instanceof CronRunning) + return c.json({ error: 'CronRunning', message: err.message }, 409) + throw err + } + }) + if (deps.auth) api.route('/', authApiRoutes(deps.auth)) if (deps.plugins) api.route('/plugins', pluginRoutes(deps.plugins)) if (deps.storage) api.route('/', storageRoutes(deps.storage)) diff --git a/apps/server/src/auth/access.ts b/apps/server/src/auth/access.ts index c3041b6..c74a9c1 100644 --- a/apps/server/src/auth/access.ts +++ b/apps/server/src/auth/access.ts @@ -1,10 +1,9 @@ -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' +import { ADMIN_ROLE, type RoleIdentity, 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}` diff --git a/apps/server/src/auth/api-keys.ts b/apps/server/src/auth/api-keys.ts new file mode 100644 index 0000000..3645074 --- /dev/null +++ b/apps/server/src/auth/api-keys.ts @@ -0,0 +1,92 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import type { MiddlewareHandler } from 'hono' +import type { Db } from '../db/index.ts' +import type { AppEnv } from '../env.ts' +import type { Logger } from '../logger.ts' + +export type ApiKeySummary = { + id: string + label: string + roles: string[] + createdBy: string + createdAt: string + lastUsedAt: string | null +} + +const hash = (key: string) => createHash('sha256').update(key).digest('hex') + +/** Store a new key and return it. This is the only time the key itself is available. */ +export async function issueApiKey( + db: Db, + key: { label: string; roles: string[] }, + by: string, +): Promise<{ id: string; key: string }> { + const id = randomUUID() + const secret = `scn_${randomBytes(32).toString('base64url')}` + await db + .insertInto('api_key') + .values({ + id, + label: key.label, + key_hash: hash(secret), + roles_json: JSON.stringify(key.roles), + created_by: by, + created_at: new Date().toISOString(), + last_used_at: null, + }) + .execute() + return { id, key: secret } +} + +export async function listApiKeys(db: Db): Promise { + const rows = await db.selectFrom('api_key').selectAll().orderBy('created_at').execute() + return rows.map((row) => ({ + id: row.id, + label: row.label, + roles: JSON.parse(row.roles_json) as string[], + createdBy: row.created_by, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + })) +} + +/** Returns false when there is no such key. */ +export async function revokeApiKey(db: Db, id: string): Promise { + const result = await db.deleteFrom('api_key').where('id', '=', id).executeTakeFirst() + return result.numDeletedRows > 0n +} + +/** Open a route to API keys holding any of the roles. */ +export function requireApiKey( + deps: { db: Db; logger: Logger }, + ...roles: string[] +): MiddlewareHandler { + return async (c, next) => { + const secret = c.req.header('authorization')?.match(/^Bearer (\S+)$/)?.[1] + const row = + secret && + (await deps.db + .selectFrom('api_key') + .select(['id', 'label', 'roles_json']) + .where('key_hash', '=', hash(secret)) + .executeTakeFirst()) + if (!row) { + deps.logger.warn( + { path: c.req.path, reason: secret ? 'unknown key' : 'no key' }, + 'API key refused', + ) + return c.json({ error: 'Unauthorized' }, 401) + } + if (!(JSON.parse(row.roles_json) as string[]).some((role) => roles.includes(role))) { + deps.logger.warn({ path: c.req.path, key: row.label }, 'API key lacks the route roles') + return c.json({ error: 'Forbidden' }, 403) + } + await deps.db + .updateTable('api_key') + .set({ last_used_at: new Date().toISOString() }) + .where('id', '=', row.id) + .execute() + deps.logger.info({ path: c.req.path, key: row.label }, 'API key accepted') + await next() + } +} diff --git a/apps/server/src/auth/role-store.ts b/apps/server/src/auth/role-store.ts index 132fa4e..2f47a77 100644 --- a/apps/server/src/auth/role-store.ts +++ b/apps/server/src/auth/role-store.ts @@ -1,3 +1,4 @@ +import { isValidDid } from '@atproto/syntax' import type { Db } from '../db/index.ts' import { ADMIN_ROLE } from './roles.ts' @@ -101,3 +102,67 @@ export async function removeMember(db: Db, role: string, did: string): Promise 0n } + +/** Refuse a plugin's change to `admin`, to a role that doesn't exist, or with an entry that isn't a DID. */ +async function checkPluginChange(db: Db, role: string, dids: string[]) { + if (role === ADMIN_ROLE) throw new Error(`Plugins can't change the "${ADMIN_ROLE}" role`) + const invalid = dids.find((did) => !isValidDid(did)) + if (invalid !== undefined) throw new Error(`"${invalid}" is not a valid DID`) + if (!(await roleExists(db, role))) throw new Error(`There is no role named "${role}"`) +} + +/** Batches that stay under the databases' limits on bound parameters. */ +const batches = (list: T[], size = 500) => + Array.from({ length: Math.ceil(list.length / size) }, (_, i) => + list.slice(i * size, (i + 1) * size), + ) + +/** + * Bring a role in line with an outside list, on behalf of `by`, such as plugin:. Drops + * members who aren't listed and adds listed DIDs that have an account. + */ +export async function syncMembers( + db: Db, + role: string, + dids: string[], + by: string, +): Promise<{ added: number; removed: number }> { + await checkPluginChange(db, role, dids) + const listed = new Set(dids) + return db.transaction().execute(async (tx) => { + const current = new Set( + (await tx.selectFrom('role_member').select('did').where('role', '=', role).execute()).map( + (row) => row.did, + ), + ) + const stale = [...current].filter((did) => !listed.has(did)) + const fresh: string[] = [] + for (const batch of batches([...listed].filter((did) => !current.has(did)))) { + const accounts = await tx + .selectFrom('account') + .select('did') + .where('did', 'in', batch) + .execute() + fresh.push(...accounts.map((row) => row.did)) + } + for (const batch of batches(stale)) + await tx + .deleteFrom('role_member') + .where('role', '=', role) + .where('did', 'in', batch) + .execute() + const now = new Date().toISOString() + for (const batch of batches(fresh)) + await tx + .insertInto('role_member') + .values(batch.map((did) => ({ role, did, added_at: now, added_by: by }))) + .execute() + return { added: fresh.length, removed: stale.length } + }) +} + +/** Add one member on a plugin's behalf, whether or not they have an account yet. */ +export async function addPluginMember(db: Db, role: string, did: string, by: string) { + await checkPluginChange(db, role, [did]) + await addMember(db, role, did, by) +} diff --git a/apps/server/src/auth/roles.ts b/apps/server/src/auth/roles.ts index 2ac43a8..f4a395f 100644 --- a/apps/server/src/auth/roles.ts +++ b/apps/server/src/auth/roles.ts @@ -1,21 +1,17 @@ -import type { Logger, RoleContext, RoleIdentity, RoleSource } from '@scn-chat/plugin-api' import type { Db } from '../db/index.ts' export const IMPLICIT_ROLE = 'user' export const ADMIN_ROLE = 'admin' -/** 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 } +/** Whose roles to work out: their DID, verified handle, and PDS. */ +export type RoleIdentity = { did: string; handle?: string | null; pdsUrl: string } -export const ROLE_SOURCE_TIMEOUT_MS = 10_000 +/** Where a role came from: the environment, an explicit membership, a PDS host, or a handle domain. */ +export type RoleGrant = { role: string; source: string } export type RolesDeps = { db: Db adminDids: ReadonlySet - /** The role sources plugins registered, from the current plugin runtime. */ - sources: () => RoleSource[] - logger: Logger - sourceTimeoutMs?: number } const parseList = (json: string) => JSON.parse(json) as string[] @@ -26,7 +22,7 @@ const pdsHost = (pdsUrl: string) => 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. */ +/** Which roles a user holds, from the database and the environment. */ export class Roles { private readonly deps: RolesDeps @@ -49,10 +45,7 @@ export class Roles { } /** Each role the user holds with where it came from. The implicit role is left out. */ - async grantsFor( - identity: RoleIdentity, - context: RoleContext = { signIn: false }, - ): Promise { + async grantsFor(identity: RoleIdentity): Promise { const { db } = this.deps const grants: RoleGrant[] = [] if (this.isEnvironmentAdmin(identity.did)) @@ -70,51 +63,12 @@ export class Roles { 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)) - const sources = this.deps.sources() - const answers = await Promise.all(sources.map((source) => this.ask(source, identity, context))) - for (const [i, source] of sources.entries()) { - for (const role of answers[i] as string[]) { - 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 } /** The implicit role plus every role the user holds. */ - async rolesFor(identity: RoleIdentity, context?: RoleContext): Promise { - const grants = await this.grantsFor(identity, context) + 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, - context: RoleContext, - ): 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, context), 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 5a293b9..9eae3dd 100644 --- a/apps/server/src/auth/routes.ts +++ b/apps/server/src/auth/routes.ts @@ -20,7 +20,7 @@ import { import type { IdentityResolver } from './identity.ts' import { removeInvite } from './invites.ts' import type { OAuthClientLike } from './oauth-client.ts' -import { ADMIN_ROLE, type Roles } from './roles.ts' +import { ADMIN_ROLE, type RoleIdentity, type Roles } from './roles.ts' import { scopeAllowsSpaces } from './scope.ts' import { createWebSession, @@ -40,6 +40,8 @@ export type AuthDeps = { access: Access settings: SettingsStore scope: string + /** Runs before the access check, so plugins can update roles first. */ + beforeSignIn?: (identity: RoleIdentity) => Promise /** Runs after each login to set up the user's storage. */ onLogin?: (account: Account) => Promise } @@ -66,6 +68,9 @@ 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 opened to API keys, which never use the session cookie. */ +const API_KEY_PATHS = [/^\/api\/cron$/] + /** API paths a viewer may use. */ const VIEWER_PATHS = [/^\/api\/me$/, /^\/api\/logout$/, /^\/api\/shared\//, /^\/api\/health$/] @@ -141,11 +146,16 @@ export const requireAdmin: MiddlewareHandler = async (c, next) => { await next() } -/** Refuse state-changing API requests from other origins. */ +/** Refuse state-changing API requests from other origins, except on routes opened to API keys. */ export function originCheck(config: Config): MiddlewareHandler { const allowed = new URL(config.publicUrl).origin return async (c, next) => { - if (c.req.method !== 'GET' && c.req.method !== 'HEAD' && c.req.header('origin') !== allowed) { + if ( + c.req.method !== 'GET' && + c.req.method !== 'HEAD' && + c.req.header('origin') !== allowed && + !API_KEY_PATHS.some((path) => path.test(c.req.path)) + ) { return c.json({ error: 'Forbidden', message: 'Cross-origin request refused' }, 403) } await next() @@ -212,7 +222,8 @@ export function oauthRoutes(deps: AuthDeps) { 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, { signIn: true }) + await deps.beforeSignIn?.(identity) + 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)) diff --git a/apps/server/src/cron.ts b/apps/server/src/cron.ts new file mode 100644 index 0000000..d703935 --- /dev/null +++ b/apps/server/src/cron.ts @@ -0,0 +1,79 @@ +import type { Db } from './db/index.ts' +import type { Logger } from './logger.ts' +import type { RuntimeHolder } from './plugins/runtime.ts' + +export const CRON_TIME_LIMIT_MS = 10 * 60_000 + +export type CronRun = { startedAt: string; finishedAt: string | null; failed: string[] } + +export class CronRunning extends Error { + constructor() { + super('A cron run is already going') + this.name = 'CronRunning' + } +} + +export type CronDeps = { + db: Db + plugins: Pick + logger: Logger + timeLimitMs?: number +} + +/** Runs every plugin's cron hook, one run at a time, and records the last run. */ +export class Cron { + private readonly deps: CronDeps + private running = false + + constructor(deps: CronDeps) { + this.deps = deps + } + + async run(): Promise { + if (this.running) throw new CronRunning() + this.running = true + try { + const startedAt = new Date().toISOString() + await this.record({ startedAt, finishedAt: null, failed: [] }) + const lease = this.deps.plugins.acquire() + const failed = await lease.runtime.host.hooks + .action( + 'cron', + { startedAt }, + this.deps.logger, + AbortSignal.timeout(this.deps.timeLimitMs ?? CRON_TIME_LIMIT_MS), + ) + .finally(() => lease.release()) + const run = { startedAt, finishedAt: new Date().toISOString(), failed } + await this.record(run) + this.deps.logger.info(run, 'cron run finished') + return run + } finally { + this.running = false + } + } + + async lastRun(): Promise { + const row = await this.deps.db.selectFrom('cron_run').selectAll().executeTakeFirst() + return row + ? { + startedAt: row.started_at, + finishedAt: row.finished_at, + failed: JSON.parse(row.failed_json) as string[], + } + : null + } + + private async record(run: CronRun) { + const values = { + started_at: run.startedAt, + finished_at: run.finishedAt, + failed_json: JSON.stringify(run.failed), + } + await this.deps.db + .insertInto('cron_run') + .values({ id: 1, ...values }) + .onConflict((oc) => oc.column('id').doUpdateSet(values)) + .execute() + } +} diff --git a/apps/server/src/db/migrations/0010_api_keys_and_cron.ts b/apps/server/src/db/migrations/0010_api_keys_and_cron.ts new file mode 100644 index 0000000..d03e241 --- /dev/null +++ b/apps/server/src/db/migrations/0010_api_keys_and_cron.ts @@ -0,0 +1,21 @@ +import { type Kysely, sql } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('api_key') + .addColumn('id', 'text', (col) => col.primaryKey()) + .addColumn('label', 'text', (col) => col.notNull()) + .addColumn('key_hash', 'text', (col) => col.notNull().unique()) + .addColumn('roles_json', 'text', (col) => col.notNull()) + .addColumn('created_by', 'text', (col) => col.notNull()) + .addColumn('created_at', 'text', (col) => col.notNull()) + .addColumn('last_used_at', 'text') + .execute() + await db.schema + .createTable('cron_run') + .addColumn('id', 'integer', (col) => col.primaryKey().check(sql`id = 1`)) + .addColumn('started_at', 'text', (col) => col.notNull()) + .addColumn('finished_at', 'text') + .addColumn('failed_json', 'text', (col) => col.notNull()) + .execute() +} diff --git a/apps/server/src/db/migrations/index.ts b/apps/server/src/db/migrations/index.ts index 37fc589..375ce21 100644 --- a/apps/server/src/db/migrations/index.ts +++ b/apps/server/src/db/migrations/index.ts @@ -8,6 +8,7 @@ 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' +import * as m0010 from './0010_api_keys_and_cron.ts' /** App migrations in order. Keys sort in the order they run. */ export const migrations: Record = { @@ -20,4 +21,5 @@ export const migrations: Record = { '0007_admin': m0007, '0008_plugin_instances': m0008, '0009_registration': m0009, + '0010_api_keys_and_cron': m0010, } diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index aa187a2..96f8170 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -95,6 +95,16 @@ export type Database = { updated_at: string } role_member: { role: string; did: string; added_at: string; added_by: string } + api_key: { + id: string + label: string + key_hash: string + roles_json: string + created_by: string + created_at: string + last_used_at: string | null + } + cron_run: { id: number; started_at: string; finished_at: string | null; failed_json: string } plugin_instance: { id: string package: string diff --git a/apps/server/src/plugins/hooks.ts b/apps/server/src/plugins/hooks.ts index 7d73472..c20e1bd 100644 --- a/apps/server/src/plugins/hooks.ts +++ b/apps/server/src/plugins/hooks.ts @@ -70,19 +70,36 @@ export class HookRunner { return current } - /** Run every action, logging failures without stopping the others. */ + /** + * Run every action, logging failures without stopping the others, until the signal aborts. + * Returns the plugins whose handler threw or didn't get to finish. + */ async action( name: N, payload: ActionHooks[N], logger: Logger, - ): Promise { + signal?: AbortSignal, + ): Promise { + const failed: string[] = [] + const aborted = new Promise((_, reject) => { + if (signal?.aborted) reject(signal.reason) + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + aborted.catch(() => {}) for (const entry of this.entries.get(name) ?? []) { + if (signal?.aborted) { + logger.error({ pluginId: entry.pluginId, hook: name }, 'plugin action skipped, out of time') + failed.push(entry.pluginId) + continue + } try { - await (entry.handler as (p: typeof payload) => unknown)(payload) + await Promise.race([(entry.handler as (p: typeof payload) => unknown)(payload), aborted]) } catch (err) { logger.error({ err, pluginId: entry.pluginId, hook: name }, 'plugin action failed') + failed.push(entry.pluginId) } } + return failed } /** The resolved handler order for each hook, by plugin ID. */ diff --git a/apps/server/src/plugins/host.ts b/apps/server/src/plugins/host.ts index 0349361..179de73 100644 --- a/apps/server/src/plugins/host.ts +++ b/apps/server/src/plugins/host.ts @@ -10,7 +10,6 @@ import { PLUGIN_API_VERSION, type Plugin, type PluginContext, - type RoleSource, type Tool, type ToolSource, } from '@scn-chat/plugin-api' @@ -37,6 +36,13 @@ export type PluginServices = { /** 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 + /** Change a role's members on behalf of `by`, such as plugin:. */ + syncRoleMembers( + role: string, + dids: string[], + by: string, + ): Promise<{ added: number; removed: number }> + addRoleMember(role: string, did: string, by: string): Promise } export type PluginHost = { @@ -44,7 +50,6 @@ export type PluginHost = { providers: Registry tools: Registry> toolSources: Registry - roleSources: Registry ingesters: IngesterRegistry hooks: HookRunner close(): Promise @@ -70,7 +75,6 @@ type Staged = { providers: ModelProvider[] tools: Tool[] toolSources: ToolSource[] - roleSources: RoleSource[] ingesters: Ingester[] hooks: { name: HookName; handler: HookHandler; order?: HookOrder }[] closers: Closer[] @@ -110,7 +114,6 @@ export async function loadPlugins( 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: () => runClosers(closers, deps.logger), @@ -121,7 +124,6 @@ export async function loadPlugins( providers: [], tools: [], toolSources: [], - roleSources: [], ingesters: [], hooks: [], closers: [], @@ -130,7 +132,11 @@ export async function loadPlugins( 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) }, + roles: { + syncMembers: (role, dids) => + deps.services.syncRoleMembers(role, dids, `plugin:${plugin.id}`), + addMember: (role, did) => deps.services.addRoleMember(role, did, `plugin:${plugin.id}`), + }, accounts: { suspend: (did, suspension) => deps.services.suspendAccount(did, `plugin:${plugin.id}`, suspension?.reason), @@ -162,7 +168,6 @@ export async function loadPlugins( 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) @@ -176,7 +181,6 @@ export async function loadPlugins( 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) diff --git a/apps/server/src/plugins/runtime.ts b/apps/server/src/plugins/runtime.ts index 3f5f2f0..eaef28c 100644 --- a/apps/server/src/plugins/runtime.ts +++ b/apps/server/src/plugins/runtime.ts @@ -1,4 +1,5 @@ import type { Logger, Plugin } from '@scn-chat/plugin-api' +import type { z } from 'zod' import { describeIssues, type Issue, issuesOf } from '../body.ts' import type { ModelCatalog } from '../providers/catalog.ts' import { safeErrorMessage } from '../safe-error.ts' @@ -40,6 +41,10 @@ export type RuntimeBuilderDeps = { logger: Logger } +/** The schema with unknown keys ignored, so options a plugin dropped in an upgrade don't break it. */ +const lenient = (schema: z.ZodType): z.ZodType => + 'strip' in schema && typeof schema.strip === 'function' ? (schema.strip() as z.ZodType) : schema + /** 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) @@ -50,7 +55,7 @@ export function instantiate(installed: InstalledPlugins, instance: PluginInstanc ) let options: unknown = { ...instance.options, ...instance.secrets } if (found.optionsSchema) { - const result = found.optionsSchema.safeParse(options) + const result = lenient(found.optionsSchema).safeParse(options) if (!result.success) { const issues = issuesOf(result.error) throw new PluginInstanceError( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a292bbd..742ed88 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,12 +4,14 @@ 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 { Roles } from './auth/roles.ts' +import { addPluginMember, syncMembers } from './auth/role-store.ts' +import { type RoleIdentity, 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 } from './config.ts' +import { Cron } from './cron.ts' import type { Db } from './db/index.ts' import { migrateToLatest } from './db/migrate.ts' import type { Logger } from './logger.ts' @@ -35,6 +37,9 @@ import { startSyncScheduler } from './sync/scheduler.ts' import { TurnRunner } from './turns/runner.ts' import { StreamHub } from './turns/stream-hub.ts' +/** How long sign-in waits for the plugins' signIn:before handlers. */ +const SIGN_IN_HOOK_LIMIT_MS = 10_000 + export type ServerDeps = { config: Config db: Db @@ -74,12 +79,7 @@ export async function createServer(deps: ServerDeps) { 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 roles = new Roles({ db, adminDids: new Set(config.adminDids) }) const access = new Access({ db, roles, settings, logger }) const hasAccess = (did: string) => access.hasAccess(did) @@ -97,6 +97,8 @@ export async function createServer(deps: ServerDeps) { userSettings: (plugin, user) => readUserSettings(db, box, user, plugin), suspendAccount: (did, by, reason) => access.suspend(did, by, reason), restoreAccount: (did, by) => access.restore(did, by), + syncRoleMembers: (role, dids, by) => syncMembers(db, role, dids, by), + addRoleMember: (role, did, by) => addPluginMember(db, role, did, by), } const load = { services: pluginServices, @@ -202,13 +204,33 @@ export async function createServer(deps: ServerDeps) { void background() } + const cron = new Cron({ db, plugins: holder, logger }) + const beforeSignIn = async (identity: RoleIdentity) => { + const lease = holder.acquire() + await lease.runtime.host.hooks + .action('signIn:before', identity, logger, AbortSignal.timeout(SIGN_IN_HOOK_LIMIT_MS)) + .finally(() => lease.release()) + } + const app = createApp({ config, db, logger, settings, webDist: deps.webDist, - auth: { config, db, logger, oauth, identity, roles, access, settings, scope, onLogin }, + auth: { + config, + db, + logger, + oauth, + identity, + roles, + access, + settings, + scope, + beforeSignIn, + onLogin, + }, plugins: { db, box, host: () => runtime().host }, storage: { db, @@ -238,7 +260,8 @@ export async function createServer(deps: ServerDeps) { resolveSigningKey: identity.resolveSigningKey, logger, }, - admin: { db, roles, access, settings, identity, plugins, runtime, logger }, + admin: { db, roles, access, cron, settings, identity, plugins, runtime, logger }, + cron, }) const stops: (() => void)[] = [] diff --git a/apps/server/tests/auth/access.test.ts b/apps/server/tests/auth/access.test.ts index baecb83..a92bc1a 100644 --- a/apps/server/tests/auth/access.test.ts +++ b/apps/server/tests/auth/access.test.ts @@ -1,11 +1,12 @@ -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 { addMember, addPluginMember, createRole } from '../../src/auth/role-store.ts' +import type { RoleIdentity } from '../../src/auth/roles.ts' +import type { Db } from '../../src/db/index.ts' import { migrateToLatest } from '../../src/db/migrate.ts' import type { Settings } from '../../src/settings/schemas.ts' import { @@ -24,18 +25,28 @@ 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[] = []) { +async function setup( + values: Partial = {}, + beforeSignIn?: (db: Db, identity: RoleIdentity) => Promise, +) { const db = createSqliteDb() await migrateToLatest(db) const settings = testSettings(db, values) - const roles = testRoles(db, { sources: () => sources }) + const roles = testRoles(db) 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 auth = authDeps(db, { + oauth, + roles, + access, + settings, + onLogin, + beforeSignIn: beforeSignIn && ((identity) => beforeSignIn(db, identity)), + }) const app = createApp({ config: testConfig(), db, logger, auth }) const signIn = (did = 'did:plc:alice', next?: string) => { as = did @@ -76,29 +87,15 @@ describe('registration modes', () => { expect(errorOf(await signIn('did:plc:carol'))).toContain('did:plc:carol') }) - it('in invite mode counts roles a role source grants', async () => { + it('in invite mode counts a role set before the access check', async () => { const { db, signIn } = await setup( { access: { registration: 'invite', inviteRoles: ['member'] } }, - [ - { - id: 'applications', - rolesFor: async ({ did }) => (did === 'did:plc:alice' ? ['member'] : []), - }, - ], + (db, { did }) => addPluginMember(db, 'member', did, 'plugin:members'), ) await createRole(db, { name: 'member', description: '' }) expect((await signIn()).headers.get('location')).toBe('/') }) - it('tells role sources whether they are asked for a sign-in or a request', async () => { - const rolesFor = vi.fn(async () => []) - const { signIn, chats } = await setup({}, [{ id: 'members', rolesFor }]) - const cookie = sessionCookie(await signIn()) - expect(rolesFor).toHaveBeenLastCalledWith(expect.anything(), { signIn: true }) - await chats(cookie) - expect(rolesFor).toHaveBeenLastCalledWith(expect.anything(), { signIn: false }) - }) - 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') diff --git a/apps/server/tests/auth/api-keys.test.ts b/apps/server/tests/auth/api-keys.test.ts new file mode 100644 index 0000000..b5b6a19 --- /dev/null +++ b/apps/server/tests/auth/api-keys.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { adminHarness } from '../helpers/admin.ts' +import { TEST_ADMIN } from '../helpers/config.ts' + +type Harness = Awaited> + +const cron = (h: Harness, key?: string, cookie?: string) => + h.app.request('/api/cron', { + method: 'POST', + headers: { + ...(key ? { authorization: `Bearer ${key}` } : {}), + ...(cookie ? { cookie } : {}), + }, + }) + +const issue = async (h: Harness, roles = ['admin']) => + (await h.call('POST', '/admin/api-keys', { label: 'crontab', roles })).body as { + id: string + key: string + } + +describe('API keys', () => { + it('returns a key once and stores only its hash', async () => { + const h = await adminHarness() + const { key } = await issue(h) + const row = await h.db.selectFrom('api_key').selectAll().executeTakeFirstOrThrow() + expect(row.key_hash).not.toContain(key) + expect(JSON.stringify((await h.call('GET', '/admin/api-keys')).body)).not.toContain(key) + expect(JSON.stringify((await h.call('GET', '/admin/api-keys')).body)).not.toContain( + row.key_hash, + ) + }) + + it('opens a route to a key holding one of its roles, and records the use', async () => { + const h = await adminHarness() + const { key } = await issue(h) + expect((await cron(h, key)).status).toBe(200) + const [listed] = (await h.call('GET', '/admin/api-keys')).body.keys as { lastUsedAt: string }[] + expect(listed?.lastUsedAt).toEqual(expect.any(String)) + }) + + it('refuses a missing, unknown, or revoked key with 401, and a key without the role with 403', async () => { + const h = await adminHarness() + await h.call('POST', '/admin/roles', { name: 'staff' }) + const { key: staffKey } = await issue(h, ['staff']) + const { id, key } = await issue(h) + await h.call('DELETE', `/admin/api-keys/${id}`) + expect((await cron(h)).status).toBe(401) + expect((await cron(h, 'scn_nope')).status).toBe(401) + expect((await cron(h, key)).status).toBe(401) + expect((await cron(h, staffKey)).status).toBe(403) + }) + + it('keeps keys and web sessions to their own routes', async () => { + const h = await adminHarness() + const { key } = await issue(h) + const res = await h.signIn(TEST_ADMIN) + const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0] + expect((await cron(h, undefined, cookie)).status).toBe(401) + const admin = await h.app.request('/api/admin/users', { + headers: { authorization: `Bearer ${key}` }, + }) + expect(admin.status).toBe(401) + }) + + it('refuses a role that does not exist, and deleting a role a key holds', async () => { + const h = await adminHarness() + expect((await h.call('POST', '/admin/api-keys', { label: 'x', roles: ['ghost'] })).status).toBe( + 400, + ) + await h.call('POST', '/admin/roles', { name: 'staff' }) + await issue(h, ['staff']) + const refused = await h.call('DELETE', '/admin/roles/staff') + expect(refused.status).toBe(400) + expect(refused.body.message).toContain('API key crontab') + }) +}) diff --git a/apps/server/tests/auth/roles.test.ts b/apps/server/tests/auth/roles.test.ts index 29b5ccd..bfdef05 100644 --- a/apps/server/tests/auth/roles.test.ts +++ b/apps/server/tests/auth/roles.test.ts @@ -1,7 +1,13 @@ -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 { describe, expect, it } from 'vitest' +import { recordLogin } from '../../src/auth/accounts.ts' +import { + addMember, + addPluginMember, + createRole, + listRoles, + syncMembers, + 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' @@ -12,26 +18,13 @@ const alice = { 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) { + async function setup() { 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, - }) + const roles = new Roles({ db, adminDids: new Set(['did:plc:boss']) }) await createRole(db, { name: 'staff', description: '' }) - return { db, roles, lines } + return { db, roles } } it('gives DIDs in ADMIN_DIDS the admin role from the environment', async () => { @@ -64,44 +57,37 @@ describe.each(dialects)('Roles on $name', ({ create }) => { expect(await roles.rolesFor({ ...alice, handle: null })).toEqual(['user']) }) - 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, { signIn: false }) - }) - - 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'] }, + it('syncs members: drops the unlisted, adds listed accounts, and keeps listed members without one', async () => { + const { db, roles } = await setup() + const account = (did: string) => + recordLogin(db, { did, handle: null, pdsUrl: 'https://p', spacesAllowed: true }) + await account(alice.did) + await account('did:plc:dana') + await addMember(db, 'staff', alice.did, 'did:plc:boss') + await addMember(db, 'staff', 'did:plc:bob', 'did:plc:boss') + await addPluginMember(db, 'staff', 'did:plc:carol', 'plugin:members') + const listed = [alice.did, 'did:plc:carol', 'did:plc:dana', 'did:plc:erin'] + expect(await syncMembers(db, 'staff', listed, 'plugin:members')).toEqual({ + added: 1, + removed: 1, + }) + const [, staff] = await listRoles(db) + expect(staff?.members.map((m) => [m.did, m.addedBy])).toEqual([ + [alice.did, 'did:plc:boss'], + ['did:plc:carol', 'plugin:members'], + ['did:plc:dana', 'plugin:members'], ]) expect(await roles.rolesFor(alice)).toEqual(['user', 'staff']) - expect(lines).toContainEqual(expect.objectContaining({ source: 'broken' })) }) - 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('ignores unknown roles and admin from a source, with a warning', async () => { - const { roles, lines } = await setup([{ id: 'greedy', rolesFor: async () => ['admin', 'vip'] }]) + it.each([ + ['admin', 'admin', alice.did], + ['a missing role', 'ghost', alice.did], + ['an entry that is not a DID', 'staff', 'bob.test'], + ])('refuses a plugin change to %s, changing nothing', async (_, role, did) => { + const { db, roles } = await setup() + await expect(syncMembers(db, role, [did], 'plugin:members')).rejects.toThrow() + await expect(addPluginMember(db, role, did, 'plugin:members')).rejects.toThrow() 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/cron.test.ts b/apps/server/tests/cron.test.ts new file mode 100644 index 0000000..2d2b568 --- /dev/null +++ b/apps/server/tests/cron.test.ts @@ -0,0 +1,83 @@ +import { type ActionHooks, definePlugin, type PluginContext } from '@scn-chat/plugin-api' +import { describe, expect, it, vi } from 'vitest' +import { adminHarness } from './helpers/admin.ts' +import { installedFrom } from './helpers/plugins.ts' + +type Setup = (ctx: PluginContext) => void + +/** A server with one plugin per setup, each added in the admin area, and an admin key for cron. */ +async function withPlugins(...setups: Setup[]) { + const installed = installedFrom( + Object.fromEntries( + setups.map((setup, i) => [ + `@test/p${i}`, + { factory: () => definePlugin({ id: `p${i}`, name: `P${i}`, apiVersion: 1, setup }) }, + ]), + ), + ) + const h = await adminHarness({ installed }) + for (const i of setups.keys()) await h.call('POST', '/admin/plugins', { package: `@test/p${i}` }) + const { key } = (await h.call('POST', '/admin/api-keys', { label: 'cron', roles: ['admin'] })) + .body as { key: string } + const run = () => + h.app.request('/api/cron', { method: 'POST', headers: { authorization: `Bearer ${key}` } }) + return { ...h, run } +} + +describe('cron', () => { + it("runs every plugin's cron hook and records the run, listing the plugins that failed", async () => { + const ran = vi.fn() + const h = await withPlugins( + (ctx) => + ctx.hooks.on('cron', () => { + throw new Error('boom') + }), + (ctx) => ctx.hooks.on('cron', ran), + ) + expect((await h.call('GET', '/admin/cron')).body.lastRun).toBeNull() + const res = await h.run() + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ failed: ['p0'] }) + expect(ran).toHaveBeenCalledWith({ startedAt: expect.any(String) }) + expect((await h.call('GET', '/admin/cron')).body.lastRun).toMatchObject({ + finishedAt: expect.any(String), + failed: ['p0'], + }) + }) + + it('refuses a run while one is going', async () => { + let finish!: () => void + const h = await withPlugins((ctx) => + ctx.hooks.on('cron', () => new Promise((resolve) => (finish = resolve))), + ) + const first = h.run() + await vi.waitFor(() => expect(finish).toBeDefined()) + expect((await h.run()).status).toBe(409) + finish() + expect((await first).status).toBe(200) + }) +}) + +describe('signIn:before', () => { + const invite = { registration: 'invite', inviteRoles: ['member'] } + + it('lets plugins set roles before the access check', async () => { + const h = await withPlugins((ctx) => + ctx.hooks.on('signIn:before', async ({ did }: ActionHooks['signIn:before']) => { + await ctx.roles.addMember('member', did) + }), + ) + await h.call('POST', '/admin/roles', { name: 'member' }) + await h.call('PUT', '/admin/access', invite) + expect((await h.signIn('did:plc:alice')).headers.get('location')).toBe('/') + }) + + it('goes ahead with sign-in when a handler fails', async () => { + const h = await withPlugins((ctx) => + ctx.hooks.on('signIn:before', () => { + throw new Error('registry down') + }), + ) + expect((await h.signIn('did:plc:alice')).headers.get('location')).toBe('/') + }) +}) diff --git a/apps/server/tests/helpers/settings.ts b/apps/server/tests/helpers/settings.ts index 2f0af28..f2e151f 100644 --- a/apps/server/tests/helpers/settings.ts +++ b/apps/server/tests/helpers/settings.ts @@ -13,7 +13,7 @@ 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 }) + new Roles({ db, adminDids: new Set([TEST_ADMIN]), ...options }) /** Roles, settings, and access over one database. */ export function testAccess(db: Db, values: Partial = {}) { diff --git a/apps/server/tests/plugins/hooks.test.ts b/apps/server/tests/plugins/hooks.test.ts index e832c4c..134f212 100644 --- a/apps/server/tests/plugins/hooks.test.ts +++ b/apps/server/tests/plugins/hooks.test.ts @@ -52,15 +52,26 @@ describe('HookRunner.action', () => { throw new Error('boom') }) hooks.add('conversation:created', 'fine', 1, later) - await hooks.action( + const failed = await hooks.action( 'conversation:created', { user: 'did:plc:a', conversation: 'at://x' }, logger, ) expect(later).toHaveBeenCalledOnce() + expect(failed).toEqual(['broken']) expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ pluginId: 'broken' }), expect.anything(), ) }) + + it('stops waiting once the signal aborts, failing the handler running and those not reached', async () => { + const hooks = new HookRunner() + const skipped = vi.fn() + hooks.add('cron', 'slow', 0, () => new Promise(() => {})) + hooks.add('cron', 'after', 1, skipped) + const failed = await hooks.action('cron', { startedAt: '' }, logger, AbortSignal.timeout(10)) + expect(failed).toEqual(['slow', 'after']) + expect(skipped).not.toHaveBeenCalled() + }) }) diff --git a/apps/server/tests/plugins/host.test.ts b/apps/server/tests/plugins/host.test.ts index 1eb2707..02d83d2 100644 --- a/apps/server/tests/plugins/host.test.ts +++ b/apps/server/tests/plugins/host.test.ts @@ -19,6 +19,8 @@ beforeEach(() => { userSettings: vi.fn(async () => ({ enabled: true })), suspendAccount: vi.fn(async () => true), restoreAccount: vi.fn(async () => true), + syncRoleMembers: vi.fn(async () => ({ added: 0, removed: 0 })), + addRoleMember: vi.fn(async () => {}), } }) @@ -74,7 +76,6 @@ describe('loadPlugins', () => { 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 }), @@ -196,6 +197,24 @@ describe('loadPlugins with onFailure', () => { expect(services.restoreAccount).toHaveBeenCalledWith('did:plc:bob', 'plugin:members') }) + it("changes a role's members on the plugin's behalf", async () => { + await loadPlugins( + [ + plugin('members', async (ctx) => { + await ctx.roles.syncMembers('staff', ['did:plc:bob']) + await ctx.roles.addMember('staff', 'did:plc:carol') + }), + ], + { services, logger, app }, + ) + expect(services.syncRoleMembers).toHaveBeenCalledWith( + 'staff', + ['did:plc:bob'], + 'plugin:members', + ) + expect(services.addRoleMember).toHaveBeenCalledWith('staff', 'did:plc:carol', 'plugin:members') + }) + it('reads the app name live', async () => { let name = 'Before' let context: PluginContext | undefined diff --git a/apps/server/tests/plugins/runtime.test.ts b/apps/server/tests/plugins/runtime.test.ts index f8e3ea0..8e089c8 100644 --- a/apps/server/tests/plugins/runtime.test.ts +++ b/apps/server/tests/plugins/runtime.test.ts @@ -91,6 +91,11 @@ describe('runtimeBuilder', () => { expect(failed('crash').error).toMatch(/kaboom/) }) + it('ignores stored options the plugin no longer has', async () => { + const built = await build([instance({ options: { name: 'kept', pollMinutes: 5 } })]) + expect(built.host.plugins.map((p) => p.id)).toEqual(['kept']) + }) + 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']) diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index b04ffa8..76523b2 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react' -import { Link, Route, Switch, useLocation } from 'wouter' +import { Link, Route, Switch, useLocation, useSearchParams } from 'wouter' import { api, json, read } from '../api.ts' import { type Schema, SchemaFields } from '../components/SchemaFields.tsx' import { useAction } from '../components/useAction.ts' @@ -19,7 +19,7 @@ type User = { } type Invite = { did: string; handle: string | null; addedBy: string; addedAt: string } type Registration = 'open' | 'invite' | 'closed' -type Member = { did: string; handle: string | null } +type Member = { did: string; handle: string | null; addedBy: string } type Role = { name: string description: string @@ -342,19 +342,41 @@ function Users() { ) } -function RoleBlock({ +/** Every role, and the admins from the environment, who belong to `admin` without being stored. */ +function useRoles() { + const [roles, setRoles] = useState(null) + const [environmentAdmins, setEnvironmentAdmins] = useState([]) + const { error, run } = useAction() + const reload = useCallback( + () => + run(async () => { + const body = await read(api.admin.roles.$get()) + setRoles(body.roles as Role[]) + setEnvironmentAdmins(body.environmentAdmins) + }), + [run], + ) + useEffect(reload, [reload]) + return { roles, environmentAdmins, error, run, reload } +} + +/** One role's description, matching rules, and explicit members. */ +function RoleForm({ role, environmentAdmins, reload, + removed, }: { role: Role environmentAdmins: string[] reload: () => void + removed: () => void }) { const [description, setDescription] = useState(role.description) const [hosts, setHosts] = useState(role.pdsHosts.join('\n')) const [domains, setDomains] = useState(role.handleDomains.join('\n')) const [identifier, setIdentifier] = useState('') + const [saved, setSaved] = useState(false) const { error, run } = useAction() const name = role.name const then = (action: () => Promise) => @@ -363,42 +385,45 @@ function RoleBlock({ reload() }) return ( -
- {name} - -