/** * Caller identity (#999, specs/2026-10-03-owner-auth.md). * * Decides who made an `/api` request from the credentials it carries — never * from a header it left out: * * | kind | credential | * |---------------|---------------------------------------------------------------| * | `agent` | a valid agent badge, or (migration window) `X-Agent-Session` | * | `relay-owner` | the in-memory relay credential (phone / portal over the relay) | * | `cloud` | the in-memory cloud credential (Cloud-forwarded Slack) | * | `scheduler` | the scheduler credential a scheduled command's child carries | * | `owner` | the owner session cookie (+ CSRF on writes) or the API token | * | `anonymous` | nothing of the above — never the owner | * * `scheduler` is neither an owner nor an agent: it may only queue a plain * message to an agent (`POST /terminal/:session/write`, see * {@link ownerOrVerifiedAgentOrScheduler}); every other gated route answers it 401. * * Any sign of an agent wins over an owner credential. The owner API token * presented from THIS machine is checked against the process tree: a client * process that belongs to an agent is that agent, not the owner. * * The answer is kept per request in a WeakMap (a client cannot set it) and * read through {@link getCallerIdentity}, {@link isOwnerCaller} and * {@link rejectNonOwner}. * * @module middleware/caller-identity */ import type { Request, RequestHandler, Response } from 'express'; import { PeerProcessService } from '../services/core/peer-process.service.js'; /** Who a caller is. */ export type CallerKind = 'owner' | 'agent' | 'relay-owner' | 'cloud' | 'scheduler' | 'anonymous'; /** How the caller was identified. */ export type CallerVia = 'owner-session' | 'api-token' | 'agent-badge' | 'legacy-header' | 'process-tree' | 'relay' | 'cloud' | 'scheduler' | 'none'; /** The identity of one request's caller. */ export interface CallerIdentity { kind: CallerKind; via: CallerVia; /** The agent session (agents only; may be unknown for a process-tree match) */ session?: string; /** The label a scheduler caller's messages carry, `scheduler:` (scheduler only) */ sender?: string; /** The owner session id (owner via session only) */ ownerSessionId?: string; /** Why an owner-looking credential was not accepted, or other detail for logs */ note?: string; } /** The request parts classification reads. */ export type IdentifiableRequest = Pick & { method?: string; socket?: { remoteAddress?: string; remotePort?: number; localPort?: number; } | null; }; /** * The owner session a request's cookies carry, if any verifies. Every * `crewly_owner_*` cookie is tried: the port suffix only keeps instances on * one host from overwriting each other, and a cookie from another instance * was signed with another secret and simply does not verify. * * @param req - Request * @returns The session, or null */ export declare function readOwnerSession(req: IdentifiableRequest): { id: string; } | null; /** * Classify without a process lookup. A local API-token caller is anonymous * here (it needs {@link callerIdentityMiddleware}'s process check to be the * owner); everything else is decided exactly as the middleware would. * * @param req - Request * @returns The identity */ export declare function classifyCallerSync(req: IdentifiableRequest): CallerIdentity; /** * Classify with the process check for a local API-token caller. * * @param req - Request * @param peers - Process classifier * @returns The identity */ export declare function classifyCaller(req: IdentifiableRequest, peers: PeerProcessService): Promise; /** Note on a legacy-header call the process tree confirmed. */ export declare const LEGACY_CONFIRMED_NOTE = "session header confirmed by the process tree"; /** * Store a request's identity (the middleware; tests). * * @param req - Request * @param identity - Identity */ export declare function setCallerIdentity(req: object, identity: CallerIdentity): void; /** * The identity of a request's caller: what the middleware decided, or a * synchronous classification for a request it did not see (unit tests that * mount a router alone). * * @param req - Request * @returns The identity */ export declare function getCallerIdentity(req: IdentifiableRequest): CallerIdentity; /** * Whether the caller holds an owner credential: the dashboard session, the * owner API token, or the phone / portal relay. * * @param req - Request * @returns True for the owner */ export declare function isOwnerCaller(req: IdentifiableRequest): boolean; /** * The agent session of the caller, when it is an agent. * * @param req - Request * @returns Session or undefined */ export declare function callerAgentSession(req: IdentifiableRequest): string | undefined; /** The 401 body for a caller with no owner credential. */ export declare const OWNER_AUTH_REQUIRED_BODY: Readonly<{ success: false; error: "owner_auth_required"; code: "owner_auth_required"; message: "This action needs the owner: use the dashboard on this machine, the phone app, or the API token (`crewly token`)."; }>; /** * Whether a request comes from a browser that holds no valid owner session: * a dashboard tab opened before the upgrade (its bundle never asks for a * session), or one whose session ended with a backend restart. * * @param req - Request * @returns True for a browser request without a session */ export declare function isBrowserWithoutSession(req: IdentifiableRequest): boolean; /** * The 401 body for a caller with no owner credential. A browser without a * session gets the plain instruction in `error` — what an old dashboard tab * shows the owner — and every body carries `code: owner_auth_required`, * which the new dashboard reacts to by refreshing its session and retrying. * * @param req - Request (optional) * @returns Response body */ export declare function ownerAuthRequiredBody(req?: IdentifiableRequest): Record; /** * Thrown by handlers that map errors to responses themselves (decisions, * project tickets): carries the owner-auth 401 body. */ export declare class OwnerAuthRequiredError extends Error { readonly body: Record; readonly status = 401; /** * @param body - The 401 body ({@link ownerAuthRequiredBody}) */ constructor(body: Record); } /** * Answer 401 owner_auth_required. No `WWW-Authenticate: Crewly-Token`: the * dashboard refreshes its session instead of prompting for the API token. * * @param res - Response * @param req - Request, so a browser without a session is told to reload */ export declare function sendOwnerAuthRequired(res: Response, req?: IdentifiableRequest): void; /** * Refuse a caller that is not the owner. Agents get `agentBody` (403, the * route's own wording); everyone else without an owner credential gets 401. * * @param req - Request * @param res - Response * @param agentBody - 403 body for an agent * @returns True when refused (response written) * * @example * ```ts * if (rejectNonOwner(req, res, { success: false, error: 'Only the owner can dismiss a ticket' })) return; * ``` */ export declare function rejectNonOwner(req: Request, res: Response, agentBody?: Record): boolean; /** * Whether the caller is the orchestrator itself, proven by its badge or the * process tree (the bare `X-Agent-Session` header is not proof). No other * agent qualifies. * * @param req - Request * @returns True for the verified orchestrator */ export declare function isOrchestratorCaller(req: IdentifiableRequest): boolean; /** * {@link rejectNonOwner}, except that the verified orchestrator passes too. * * For actions the owner can ask the orchestrator to do on their behalf * (owner-away rule: the owner is on the phone and talks to the orc, so a * gate that only the dashboard can open leaves the request hanging). Every * other agent still gets the route's 403. * * @param req - Request * @param res - Response * @param agentBody - 403 body for any other agent * @returns True when a response was sent */ export declare function rejectNonOwnerOrOrchestrator(req: Request, res: Response, agentBody?: Record): boolean; /** * Express middleware form of {@link rejectNonOwner}. * * @param req - Request * @param res - Response * @param next - Next */ export declare const requireOwner: RequestHandler; /** * Owner-only decisions with an audit fingerprint (OKR approve/reject, signal * digest items). Agents get 403 `owner_approval_required`; a caller with no * owner credential gets 401. Before #999 this required the raw API token * even from loopback, so the local dashboard could not use it; the owner * session now counts too. * * On success `res.locals.ownerTokenFingerprint` names the credential for the * audit trail: 8 hex of sha256(API token), `session-<8 hex>` for a dashboard * session, or `relay`. * * @param req - Request * @param res - Response * @param next - Next */ export declare const requireOwnerToken: RequestHandler; /** * {@link requireOwner} with the route's own 403 wording for agents. * * @param agentBody - 403 body for an agent * @returns Express middleware */ export declare function ownerOnly(agentBody: Record): RequestHandler; /** * Whether an identity is an agent proven by a credential: its badge, or the * process tree (an agent PTY's process presented the owner token). The * legacy `X-Agent-Session` header alone is not proof — any local process can * set it. * * @param identity - Caller identity * @returns True for a badge or process-tree agent with a known session */ export declare function isVerifiedAgent(identity: CallerIdentity): boolean; /** * Refuse a caller that is neither the owner nor a verified agent * ({@link isVerifiedAgent}). An agent with only the legacy header gets 403 * `agent_badge_required`; everyone else without a credential gets 401. * * @param req - Request * @param res - Response * @param action - What the caller tried, for the 403 message (e.g. "Writing into an agent's terminal") * @returns True when refused (response written) */ export declare function rejectUnverifiedCaller(req: Request, res: Response, action: string): boolean; /** * Express middleware form of {@link rejectUnverifiedCaller}: the owner, or an * agent identified by its badge (or the process tree). * * @param action - What the route does, for the 403 message * @returns Express middleware * * @example * ```ts * router.post('/terminal/:s/write', ownerOrVerifiedAgent("Writing into an agent's terminal"), handler); * ``` */ export declare function ownerOrVerifiedAgent(action: string): RequestHandler; /** * {@link ownerOrVerifiedAgent} for the one route a scheduled command may use: * delivering a plain message to an agent. A `scheduler` caller passes only with * `mode: "message"` and no `workItemId` (a hand-over can clear the agent's * conversation); anything else is 403 `scheduler_message_only`. Owner and * verified agents are treated exactly as {@link ownerOrVerifiedAgent} does. * * @param action - What the route does, for the 403 message * @returns Express middleware */ export declare function ownerOrVerifiedAgentOrScheduler(action: string): RequestHandler; /** * Build the `/api` middleware. * * @param peers - Process classifier. The server passes one that knows the * live agent PTYs (`liveSessionPids` from the agent-origin middleware), so * the terminal check and session names work; without it only the * ancestry and environment checks run. * @returns Express middleware */ export declare function createCallerIdentityMiddleware(peers?: PeerProcessService): RequestHandler; //# sourceMappingURL=caller-identity.middleware.d.ts.map