/** * daemon/inbound.ts — letting the outside world reach a session. * * The Todoist channel proved the shape: something happens elsewhere, it becomes * a message addressed to a named session, and that session decides what to do * about it. Everything about that is general except Todoist. This is the same * path with the source removed — a POST from anything that can call a webhook. * * The endpoint is served on the public Tailscale Funnel alongside the Todoist * webhook, which makes it the most exposed thing on the machine, so the design * is deliberately narrow: * * - **A route names its session; the payload never does.** If callers could * pick the target, whoever found the URL would choose which session runs * with your rights. Routing is a decision made once, at the terminal, and * recorded — the same reasoning as ingress grants. * - **Payload is data, not instruction.** It arrives prefixed and framed as * external content. The session reads it and decides; nothing here executes * anything, and the framing says so explicitly so a session cannot mistake a * delivered document for an order from its operator. * - **No secret, no route.** Compared in constant time, because an endpoint * that leaks token bytes through timing is not protected by having a token. * - **Bounded.** Size cap, rate limit per route, and every accept and refusal * in the audit trail. A refusal nobody records is a probe nobody notices. * * What this is NOT: a way for a cloud service to run commands here. Two hops is * the pattern that keeps it safe — an inbound message reaches a session, and * that session, applying its own judgement, may hand work onward to another. */ /** Path prefix served by the webhook server. `/hook/`. */ export declare const HOOK_PREFIX = "/hook/"; /** Header carrying the route secret. */ export declare const TOKEN_HEADER = "x-aibroker-token"; /** Largest body accepted, before parsing. */ export declare const MAX_BODY: number; export type InboundMode = "message" | "task"; export interface InboundRoute { /** Path segment: `/hook/`. Lowercase, no slashes. */ name: string; /** Shared secret the caller must present. Never logged. */ secret: string; /** * Session that receives everything on this route. * * Fixed per route on purpose. See the module note: a caller-chosen target is * a caller-chosen executor. */ owner: string; /** * `message` delivers to the session's mailbox. * `task` files a Todoist task, so a human sees it before a session acts. */ mode: InboundMode; /** * Optional field paths to lift out of the payload, in order, e.g. * `["subject", "from.address"]`. Missing paths are skipped rather than * rendered as "undefined" — a template that lies about a field is worse than * one that omits it. Unset means "summarise the whole payload". */ fields?: string[]; /** * Events to drop silently, as `path=value` against the payload. * * The case that made this necessary: a session that comments on an issue * causes the tracker to call this hook, which delivers the session its own * comment, which it may answer — a loop with a network hop in it. The sender * is in the payload, so the fix is to name it rather than to reason about it. * * Matching is exact and case-insensitive on the value. A path that is absent * never matches, so a payload shape that changes fails open — it delivers, * rather than silently swallowing everything. */ ignore?: string[]; /** * Hold events briefly and deliver them as one. * * One human action often fires several webhooks — writing a comment and * closing an issue in the same breath produces two, and each one wakes a * session separately. Grouping by a key in the payload (the issue number) * turns that back into the single interruption the person actually caused. * * `ms` is how long to wait after the LAST event in a group, not the first, * so a burst collapses and a slow trickle still arrives promptly. */ coalesce?: { ms: number; key: string; }; /** * Senders whose messages are from the operator, as `path=value`. * * The default framing tells a session that what follows is data from a * stranger and that it must not act on instructions inside it. That is right * for an endpoint anyone could find, and wrong for the one case that matters * most: the operator writing a comment on their own issue. Framed as a * stranger, "next step, please look at this" reads as something to ignore. * * So a route may name the senders it trusts, and only those are framed as * the operator speaking. Everything else keeps the strict framing, because * the endpoint is still public and the sender field is still just a claim * made by whoever signed the request. */ trusted?: string[]; /** A one-line human note about what sends here. */ note?: string; /** * The repository this route carries, as `owner/name`, when one made it. * * Recorded so that a second route for a repository that already has one is * visible in the listing rather than only in the traffic. Absent on routes * built by hand before this existed, so it may be read but never relied on * as the only way to tell what a route carries. */ repo?: string; enabled?: boolean; createdAt: string; } export declare function listRoutes(): InboundRoute[]; export declare function findRoute(name: string): InboundRoute | undefined; /** Create or update a route. Returns the route, with a generated secret if new. */ export declare function addRoute(name: string, opts: { owner: string; mode?: InboundMode; fields?: string[]; ignore?: string[]; trusted?: string[]; coalesce?: { ms: number; key: string; }; note?: string; repo?: string; secret?: string; }): InboundRoute; /** * Change WHICH fields a route lifts, without touching its credential. * * Until this existed, the only way to add a field was to recreate the route — * which rotates the secret, so adjusting what a notification *displays* meant * going and re-pasting a password into the sending system. Coupling a display * setting to a credential rotation is how a route ends up wrong forever * instead of being corrected in ten seconds. */ export declare function setRouteFields(name: string, fields: string[]): InboundRoute | undefined; export declare function removeRoute(name: string): boolean; /** * Constant-time secret comparison. * * `timingSafeEqual` throws on a length mismatch, which would leak the length * through the exception path, so lengths are compared into the same boolean * rather than short-circuiting the function. */ export declare function secretMatches(presented: string | undefined, expected: string): boolean; /** Header a git forge signs the body with, using the route's secret as key. */ export declare const SIGNATURE_HEADER = "x-gitea-signature"; /** * A body signed with the route's secret, rather than the secret itself. * * Some callers cannot send an arbitrary header but can sign what they send — * git forges are the case that prompted this: a webhook is configured with a * secret and proves it by HMAC over the raw body. Accepting that means one * secret works either way, instead of teaching every caller a custom header or * putting a token in a URL where it would end up in logs. * * The raw bytes matter. Verifying a re-serialised body checks a string we * produced rather than the one that arrived, which is not a check at all. */ export declare function signatureMatches(raw: Buffer, presented: string | undefined, secret: string): boolean; /** * Either proof is enough: the secret in a header, or a signature over the body. * * Both are the same secret. A caller that can do neither is not authenticated, * and "no proof offered" and "wrong proof" are answered identically so that * probing cannot tell them apart. */ export declare function authorised(raw: Buffer, headers: { token?: string; signature?: string; }, secret: string): boolean; /** True when this route is over its allowance. Prunes as it goes. */ export declare function rateLimited(routeName: string, now?: number): boolean; export declare function renderPayload(route: InboundRoute, payload: unknown): string; export declare function composeDelivery(route: InboundRoute, payloads: unknown | unknown[]): string; export interface DeliveryResult { ok: boolean; /** What happened, for the audit trail and the caller's 200 body. */ detail: string; } /** * Record that this machine just wrote to an issue, so its echo can be known. * * `scope` is the PLACE that was written to — `owner/repo` for a forge — and * deliberately not the route the news will come back down. Both read the same * while a repository had exactly one route. On 2026-09-02 one had two: a * hand-made route from August and the derived-name one `subscribe_issues` * creates, with the forge posting every event to both. The write was recorded * under the route the permission check resolved, so that copy was dropped and * the other was delivered — the audit trail said the echo was suppressed and * the session got it anyway, at 19:52:49 on #489: * * inbound external hook:owner-repo ignored own write to #489 * inbound hook:a-tracker → ... delivered held for grouping * * Keying by the place written to suppresses every copy, however many hooks the * forge has been given. */ export declare function noteOwnWrite(scope: string, issue: number | string): void; /** For tests, and for a daemon that wants a clean slate. */ export declare function forgetOwnWrites(): void; export declare function shouldIgnore(route: InboundRoute, payload: unknown): string | undefined; /** * Is this from somebody the route trusts? * * Exported because the answer changes how a session is told to read the * message, and a security decision that cannot be tested on its own is one * nobody can check. */ export declare function isTrusted(route: InboundRoute, payload: unknown): boolean; export declare function deliverInbound(route: InboundRoute, payload: unknown): Promise; //# sourceMappingURL=inbound.d.ts.map