/** * The phone ↔ principal binding, and the short code that mints it. * * LINK FROM PRODUCT, never phone-lookup auth: a link only ever exists because a * signed-in user asked for one and then texted its code back. Vendo Cloud knows * phone→deployment routing and nothing else; the binding lives HERE, in the * deployment's own composed store, so a host owns its users' phone numbers the * same way it owns everything else about them. * * Shaped like `ThreadRepository` (threads.ts): rows through the adapter seam * only, so a hosted store serves it too, and the refs carry the subject so * `eraseStore().bySubject` sweeps a departing user's link with the rest. */ import { type ApprovalId, type AutomationId, type IsoDateTime, type StoreAdapter } from "./core/index.js"; /** What a claim code looks like once normalized — the cheap test an inbound * text passes before it is worth a lookup. */ export declare const CODE_PATTERN: RegExp; /** How long a minted code stays claimable. Thirty minutes because a person taps * the link on one device and finishes on another, and because the router hands * back a contact card first — the gap between tapping and texting is human, not * mechanical. Still short enough that a code read over someone's shoulder is * worthless by the time they act on it. */ export declare const LINK_CODE_TTL_MS: number; export interface ChannelLink { id: string; subject: string; /** The outstanding claim code. Absent once the link is claimed. */ code?: string; /** When the code stops being claimable. Absent once claimed. */ expiresAt?: IsoDateTime; /** The phone that claimed it, in E.164. Absent while the link is pending. */ phone?: string; linkedAt?: IsoDateTime; /** The conversation's rolling thread, and when it last ran. The channel keeps * its OWN thread rather than reusing whatever the subject touched last: the * newest thread is usually a web chat, and a text turn would both hijack it * and persist the texting style into every later web turn on it. */ threadId?: string; lastTurnAt?: IsoDateTime; /** The router conversation this phone last texted on — the ONLY address the * channel can send to (`ChannelsService.send` takes a conversation, never a * number, because the deployment never learns the router's addressing). It is * what lets `vendo_text_me` reach this person from a web turn or an away * firing. Absent until they have sent at least one real message: a one-text * link carries no conversation of its own (`InboundLinkEvent`). */ conversationId?: string; } /** What a person sees when a surface names their linked phone: enough to * recognize their own number, never enough to read someone else's. */ export declare function maskPhone(phone: string): string; /** Codes are compared case- and space-insensitively: the person retyping one * is on a phone keyboard that may capitalize, and they may or may not paste * the spaces around it. */ export declare function normalizeCode(text: string): string; /** One phone, one spelling. A vendor that delivers `+15551234567` on one * message and `1 (555) 123-4567` on the next would otherwise leave the same * physical phone holding two link rows on two accounts — and the second * spelling would read as a stranger. */ export declare function normalizePhone(phone: string): string; export declare class ChannelLinkRepository { private readonly store; constructor(store: StoreAdapter); /** Mint a fresh code for this subject, replacing any code they had * outstanding. An already-claimed link is left alone: asking for a new code * must not silently unlink the phone the user is texting from. */ mint(subject: string): Promise; /** The second text of the link: the code arrives from the phone we are about * to bind. Answers the claimed link, or null when the code is unknown, * already spent, or expired. */ claim(code: string, rawPhone: string): Promise; /** Who this phone is, for an inbound text. NEWEST claim wins: `claim` reads * the rows it replaces and writes separately, so two claims racing on the * same phone with two different live codes can each leave a row behind. * Taking whichever the store happened to list first would then run the * phone's texts as an arbitrary one of the two; ordering by `linkedAt` lands * on the subject a serialized pair would have left bound, which is this * file's rule — the later claim replaces the earlier. */ byPhone(rawPhone: string): Promise; /** Remember which thread this conversation is running in, and when it last * ran — the two facts `runChannelTurn` rolls the thread on — plus the * conversation itself, which is the address `vendo_text_me` sends to. */ rememberTurn(link: ChannelLink, threadId: string, conversationId: string): Promise; /** This subject's claimed link, if they have one. */ bySubject(subject: string): Promise; /** Drop everything this subject has here — the claimed phone and any code * still outstanding. */ unlink(subject: string): Promise; private records; private expired; private pendingFor; private claimedFor; /** A 6-character code is retyped by a human, so it is short enough that two * live codes could collide — and a collision would hand one person's link to * another. Mint against the rows that exist. */ private freeCode; /** Follows the store's pagination cursor to exhaustion, like * ThreadRepository.listRecords. */ private listBy; } /** * The approvals THIS conversation asked about. * * Load-bearing, not bookkeeping: a "YES" must only ever decide a card that went * out over this channel. `guard.approvals.pending` is scoped to the subject, so * deciding the newest pending one would let a text approve the card the person * is looking at in a web tab — consent for a money-moving call, given on a * surface that never showed it. * * In the STORE rather than in memory, because the ask and its answer arrive as * two separate inbound deliveries and a deployment is a request handler: on a * serverless host those two land on different instances, and a restart parts * them anywhere. A composition-scoped map answers the second delivery with an * empty set, so the "YES" reads as an ordinary message and the card the person * WAS shown sits pending until it times out. Rows carry the subject, so * `eraseStore().bySubject` sweeps them with the link. */ export declare class ChannelAskRepository { private readonly store; constructor(store: StoreAdapter); /** Remember that this conversation was told about this approval. The approval * id is the row id, so recording the same ask twice leaves one row. */ add(subject: string, conversationId: string, approvalId: ApprovalId): Promise; /** The approvals this conversation may answer. */ ids(conversationId: string): Promise; /** Spend the row the moment its card is decided: a card answered once is not * answerable again, and the rows must not outlive the conversations. */ consume(approvalId: ApprovalId): Promise; /** Remember that this conversation was asked about one automation's whole set * of outstanding permissions, and which approvals that one text covers. * * Keyed by the AUTOMATION, not by the engine's own `gset_` id: the engine * mints exactly one grant set per record — arming reuses the record's still * pending set and a fire-time miss joins it (automations `consent.ts`, * `captureGrants` and `needsPermission`) — so the automation names the same * set without this file reading the engine's private capture rows. The row * lives from the moment the text lands until a YES or NO settles it, which * makes it both what a bare reply routes to and the memory that stops a later * turn asking the same set twice. */ addSet(subject: string, conversationId: string, automationId: AutomationId, approvals: readonly ApprovalId[]): Promise; /** The grant set ask this conversation is holding, if any. One at a time, like * the cards: whichever row the store lists first is the open question, and * nothing new goes out while one is here. */ setAsk(conversationId: string): Promise; /** Spend the set row: its question has been answered, or answered elsewhere. */ consumeSet(automationId: AutomationId): Promise; private records; } /** One automation's outstanding permissions, as this conversation was asked * about them. */ export interface ChannelGrantSetAsk { automationId: AutomationId; approvals: readonly ApprovalId[]; } /** * Which deliveries this deployment has already run. * * In the store for the same reason the ask rows are: `eventId` is the wire * contract's idempotency key, and a Set in one instance's memory cannot honour * it. Cloud retries a delivery that did not answer 202, and on a serverless host * that retry is a different instance — which would run the person's text a * SECOND time, with a second tool call and a second charge behind it. * * The claim IS the adapter's conditional insert wherever there is one, so the * winner is decided by the store rather than by a read the next copy of the * delivery could race. * * The row holds the event id and a timestamp, never the phone or the text, so * there is nothing here for `eraseStore().bySubject` to have to reach. */ export declare class ChannelEventLog { private readonly store; constructor(store: StoreAdapter); /** Per conversation, not one clock for the process: a single shared clock * lets one chatty conversation spend every interval, and every other * conversation's expired rows are then never considered again. */ private readonly sweptAt; /** True when this delivery is ours to run, false when it already ran. */ claim(eventId: string, conversationId: string): Promise; /** Drop this conversation's deliveries once they are older than any retry. */ private prune; private records; }