Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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<ContextOptions>(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<string, number>();
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];
}