/** * daemon/todoist-webhook.ts — Todoist as an inbound channel. * * File a task from a phone or a watch and it reaches the session that owns the * project, without a poller. Todoist pushes; the daemon routes it to `dispatch`. * * ── Why webhooks rather than polling ──────────────────────────────────────── * * Polling forces a choice between latency and cost, and gets both wrong. The * decisive detail is that `reminder:fired` is a webhook event: a task with a * reminder pushes AT the reminder time. Due dates do NOT push — a task merely * becoming due fires nothing — so scheduling is expressed as a reminder, and * "run the sweep at 09:00" needs no timer anywhere in this system. * * ── This is an execution ingress, so it is deliberately narrow ────────────── * * A task arriving here becomes an instruction a session acts on with the * user's full rights. Todoist's own payload documents that `initiator` "may be * the same user indicated in user_id OR A COLLABORATOR FROM A SHARED PROJECT", * which is precisely the exposure: without a boundary, anyone on any shared * project has a path to this machine. Hence: * * - Only tasks under a configured ingress project (or its children) are * considered. Everything else is acknowledged and dropped. * - Every request must carry a valid HMAC signature. * - Everything — accepted, ignored, refused — goes through the audit trail. * * ── Echo loops ────────────────────────────────────────────────────────────── * * `note:added` is an event, so an agent commenting on a task triggers a webhook * that could route back to an agent that comments again. `initiator` cannot * break the cycle: when agents act as the user, the initiator IS the user. Our * own writes are therefore marked, and marked content is ignored on the way in. */ import { type Server } from "node:http"; /** Prefix on anything an agent writes back to Todoist, so we ignore our own echo. */ export declare const AGENT_MARK = "\uD83E\uDD16"; /** * Label a runner puts on a task it has claimed. * * PAI's poller sets it before doing anything else, so a crash leaves the task * visibly in flight rather than silently dispatched twice. The webhook path * honours the same label: two mechanisms watching the same checkbox must not * both fire for one tick. */ export declare const RUNNING_LABEL = "pai-running"; /** * Is this task a click-to-run trigger rather than a piece of work? * * Two conditions, and both matter. It RECURS, so ticking it reschedules rather * than ends it — that is what makes a checkbox usable as a button. And it * carries an explicit routing label, so it was built to be dispatched: without * that, a recurring shopping list in an allowed project becomes a work order * the first time someone ticks it. */ export declare function isTrigger(data: Record): boolean; /** * Did this update turn an unroutable task into a routable one? * * Routable means both halves of the boundary are satisfied: the task sits in an * ingress project AND names an owner. Either alone is not enough — the project * is the security boundary, the label or project mapping is the address. * * Needs `event_data_extra.old_item`, which Todoist includes for updates a user * made directly. Without it there is no transition to observe and the update is * ignored, which is the safe direction: acting would mean dispatching on an * edit we cannot characterise. */ export declare function routabilityTransition(e: TodoistEvent, cfg: WebhookConfig): { becameRoutable: boolean; reason: string; }; export interface TodoistEvent { event_name: string; user_id?: string; event_data?: Record; event_data_extra?: Record; initiator?: { email?: string; full_name?: string; id?: string; }; triggered_at?: string; version?: string; } export interface WebhookConfig { /** Client secret from the App Management Console — signs every request. */ secret: string; port: number; /** * Interface to bind. Loopback by default. * * Todoist calls from the public internet, so SOMETHING must expose this — * but that something should terminate TLS and forward to loopback, not have * this process on a public interface. Tailscale Funnel, Cloudflare Tunnel and * a Caddy/nginx reverse proxy all work that way. Note Funnel specifically, * not Serve: Serve is reachable only from inside the tailnet, and Todoist's * servers are not in it. */ bind: string; /** Path the receiver answers on. Anything else 404s. */ path: string; /** * Client id from the App Management Console. * * Only needed for the OAuth landing below — the webhook path itself is * verified by HMAC and never uses it. */ clientId?: string; /** * Path the OAuth redirect lands on. * * Todoist rejects a redirect URL carrying a port, so this one cannot be * reached over the same Funnel as the webhook. Put a tailnet-only Serve on * 443 in front of it — see docs/todoist.md. */ oauthPath: string; /** * Project ids allowed to reach a session. An explicit allowlist, not a * subtree resolved at runtime, and deliberately so: a project added later * does not silently become an execution ingress. It has to be granted. */ ingressProjectIds: Set; /** project id -> session that owns it, for tasks filed straight into a project. */ projectOwners: Map; /** * Where a task with no owner goes — the watch case, where everything lands * in the Inbox with no project and no label. Unset means such tasks are * recorded and dropped rather than guessed at. */ defaultOwner?: string; } /** Which rule chose the owner. Recorded, so a wrong guess is visible. */ export type OwnerRule = "label" | "address" | "held" | "project" | "bare-label" | "default"; export type RouteDecision = { act: true; project: string; body: string; taskId: string; rule: OwnerRule; nearMiss?: string; } | { act: false; reason: string; nearMiss?: string; }; /** * Pull a leading session name off the content. * * Typing a label is several taps on a phone and worse on a watch, so the * cheapest possible address is the first word: "pai send a whatsapp message". * Only names we already know are accepted — otherwise "home improvements" * becomes a work order for the Home session — and an explicit `name:` or * `name,` counts as addressing even when the name is unknown, so that a typo * can be reported instead of silently ignored. */ export declare function parseAddress(content: string, known: Set): { owner?: string; rest: string; firstToken: string | null; addressed: boolean; }; /** * Verify Todoist's HMAC over the RAW body. * * Must run against the exact bytes received: re-serialising parsed JSON changes * key order and whitespace and the signature will never match. */ export declare function verifySignature(raw: Buffer, header: string | undefined, secret: string): boolean; /** Stable identity for an event, for replay/duplicate suppression. */ export declare function eventKey(e: TodoistEvent): string; /** * Decide whether an event should reach a session, and as what. * * Pure, so the routing rules are testable without a socket: this function is * the entire security boundary of the ingress. */ export declare function route(e: TodoistEvent, cfg: WebhookConfig, knownOwners?: Iterable, /** * The session already holding this task, when we know it. * * Only meaningful for a comment: a follow-up belongs to whoever did the work, * not to whatever the project mapping would pick today. */ heldBy?: string): RouteDecision; export interface WebhookDeps { /** * Deliver a work order. Returns a short outcome for the audit record. * * `prefix` distinguishes a correction from a new work order: `[Task]` reads * as "start this", and a session that receives a comment under that heading * will begin again rather than adjust. */ deliver: (project: string, body: string, opts?: { prefix?: string; }) => Promise<{ outcome: string; session: string; reason?: string; }>; /** * Names that may be used as an address in a task's first word. * * Must be the names DISPATCH can resolve, not merely the ones a human would * recognise. Addressing a live session that dispatch has no alias for reads * as success and delivers nothing — which is exactly how "paicloud send me a * mail" was quietly answered by the wrong session. * * A function rather than a list: aliases and sessions both change while the * daemon runs, and a name that was valid at boot need not be valid now. */ knownOwners?: () => string[] | Promise; } /** * `cfg` is null when Todoist itself is not configured (or was refused — * see the empty-ingress case in `startTodoistWebhook`). The listener still * comes up whenever A2A needs it; every Todoist-shaped path below falls * through to the same 404 an unknown route gets, so the two surfaces read * as independent from the outside — a prober cannot tell "Todoist is off" * from "no such route" any more than it already could tell a wrong secret * from a nonexistent one. */ export declare function createWebhookServer(cfg: WebhookConfig | null, deps: WebhookDeps): Server; /** Read config from the environment. Returns null when not configured. */ export declare function webhookConfigFromEnv(): WebhookConfig | null; /** Is Todoist itself configured — the client secret its webhooks are signed with? */ export declare function todoistConfigured(): boolean; /** * Should the shared HTTP listener bind at all? * * Todoist and A2A are two independent callers of the one thing that is hard * to arrange — a public HTTPS endpoint — sharing this listener the same way * `/hook/*` already shares it with both. Neither may gate the other: an A2A * agent gated behind an unrelated Todoist secret is not shippable, and * Todoist must keep working exactly as before when A2A is untouched. * * `exposureFile` is a test seam — see `a2aConfigured()`. */ export declare function sharedListenerShouldStart(exposureFile?: string): boolean; /** Start the shared listener if either Todoist or A2A is configured. No-op otherwise. */ export declare function startTodoistWebhook(deps: WebhookDeps): Server | null; //# sourceMappingURL=todoist-webhook.d.ts.map