import type { FastifyInstance, FastifyRequest } from 'fastify'; import fp from 'fastify-plugin'; import type { Account } from '@wellformed/shared'; import { unauthorized } from '../errors.js'; import type { ControlDb } from '../control/db.js'; import { touchLastSeen } from '../control/accounts.js'; import { resolveSession, SESSION_COOKIE } from '../control/sessions.js'; import { getAccountById } from '../control/accounts.js'; import { resolveAgentKey, touchAgentKey, type AgentKeyRecord } from '../control/agent-keys.js'; import type { TenantRegistry } from '../tenancy/registry.js'; import type { Actor } from '../engine/audit.js'; declare module 'fastify' { interface FastifyInstance { control: ControlDb; registry: TenantRegistry; } interface FastifyRequest { /** Who is calling, installation-wide. Says nothing about what they may reach. */ account: Account | null; /** * The agent key this request arrived on, when it came from an assistant * rather than a browser. * * Setting this does not widen anything: the account is still the account, * and the key's workspace and ceiling only ever narrow what that account * could already do. The tenant plugin is where both are applied. */ agent: AgentKeyRecord | null; /** Throws 401 when unauthenticated. */ requireAccount(): Account; actor(): Actor; } } export interface ContextOptions { control: ControlDb; registry: TenantRegistry; } /** * Attaches the control database, the workspace registry and the calling * account to every request. * * Authentication only. Authorisation is deliberately not global: a route is * public until it asks otherwise, so a route added without a `requireAccount()` * or a workspace scope is visibly public rather than accidentally so. */ export const contextPlugin = fp(async (app: FastifyInstance, opts) => { const { control, registry } = opts; app.decorate('control', control); app.decorate('registry', registry); app.decorateRequest('account', null); app.decorateRequest('agent', null); app.decorateRequest('requireAccount', function (this: FastifyRequest): Account { if (!this.account) throw unauthorized(); return this.account; }); app.decorateRequest('actor', function (this: FastifyRequest): Actor { if (!this.account) return { id: null, name: 'anonymous' }; return { id: this.account.id, name: this.account.name, via: this.agent?.label ?? null }; }); const lastTouch = new Map(); app.addHook('onRequest', async (request) => { /** * A presented agent key wins over an ambient cookie. * * They should never both be there — an assistant has no cookie jar — but if * they are, the credential the caller went out of its way to send is the * one it meant, and it is the narrower of the two. */ const agent = resolveAgentKey(control, bearerToken(request)); if (agent) { const account = getAccountById(control, agent.accountId); // A disabled account takes its keys down with it, same as its sessions. if (account && !account.disabled) { request.agent = agent; request.account = account; touchAgentKey(control, agent.id); return; } } const token = request.cookies[SESSION_COOKIE]; request.account = resolveSession(control, token); // Refresh `last_seen_at` at most once a minute per account; it is a nicety, // not a reason to write to disk on every request. if (request.account) { const now = Date.now(); const previous = lastTouch.get(request.account.id) ?? 0; if (now - previous > 60_000) { lastTouch.set(request.account.id, now); touchLastSeen(control, request.account.id); } } }); }); function bearerToken(request: FastifyRequest): string | undefined { const header = request.headers.authorization; if (!header) return undefined; const match = /^Bearer\s+(\S+)$/i.exec(header); return match?.[1]; }