/** * Framework-agnostic connect-flow state machine for the browser two-tab helper. * * @remarks * This is the testable core behind {@link useDirectVanaConnect}. It is pure * TypeScript (no React, no DOM-only APIs beyond an injectable window opener and * timers) so the full flow — create request, open Vana, poll status, read data — * can be exercised in a Node test environment. * * The React hook is a thin `useSyncExternalStore` binding over this store. * * @category Direct * @module direct/connect-flow */ import type { AccessRequest, AccessRequestStatus, ApprovedDataResult } from "./types.js"; /** * Caller-supplied transports. These typically `fetch` the app's own backend * routes, which in turn delegate to a {@link DirectDataController}. */ export interface DirectConnectTransports { /** Ask the backend to create an access request. */ createRequest: () => Promise; /** Ask the backend for the current status of a request. */ getStatus: (requestId: string) => Promise; /** Ask the backend to read the approved data. */ readResult: (requestId: string) => Promise>; } /** * A handle to a tab opened synchronously under the user's click gesture. * * @remarks * The flow opens this tab *before* it knows the approval URL (popup blockers * only allow `window.open()` during the click's transient activation), then * navigates it once `createRequest` resolves. */ export interface ConnectWindow { /** Point the already-open tab at the approval URL. */ navigate(url: string): void; /** Close the tab (used to clean up an un-navigated tab on failure/reset). */ close(): void; } /** Browser class used only to choose the destination returned by Vana. */ export type DirectBrowserPlatform = "desktop" | "mobile"; /** Injectable browser-platform policy; it never asserts whether an app exists. */ export interface DirectBrowserPlatformPolicy { current(): DirectBrowserPlatform; } /** Tunables for the connect flow. */ export interface DirectConnectOptions { /** Status poll interval in ms. Defaults to 1500. */ pollIntervalMs?: number; /** * Overall timeout in ms before giving up. Defaults to 300000 (5 min). * Used only when the access request does not carry an authoritative * `expiresAt` value. */ timeoutMs?: number; /** * Synchronously open a blank tab under the click's transient activation and * return a handle to navigate later, or `null` if the browser blocked it. * Defaults to `window.open("", "_blank")` (with `opener` severed). Injectable * for tests. * * @remarks * Renamed from the pre-3.8 `openWindow?: (url) => void`. The old contract was * the BUI-622 bug itself (it was called with the URL *after* an `await`, so * the popup blocker suppressed it); it cannot be preserved while fixing the * bug. Custom openers must now open synchronously and return a navigable * handle. */ openApprovalWindow?: () => ConnectWindow | null; /** SDK-owned mobile/desktop policy. Injectable for deterministic tests. */ browserPlatformPolicy?: DirectBrowserPlatformPolicy; /** `setTimeout`. Injectable for tests. Defaults to `globalThis.setTimeout`. */ setTimeoutFn?: (cb: () => void, ms: number) => unknown; /** `clearTimeout`. Injectable for tests. Defaults to `globalThis.clearTimeout`. */ clearTimeoutFn?: (handle: unknown) => void; /** Clock source in ms. Injectable for tests. Defaults to `Date.now`. */ now?: () => number; } /** * Discriminated connect-flow state. * * @remarks * `type` matches the builder guide: it starts at `"idle"` and is non-idle while * connecting. The intermediate phases give richer UIs something to render. * * Desktop and light-data requests move through `"awaiting_approval"` (Vana Web * opens in a popup). A deep Direct request on a mobile browser moves through * `"ready_to_open"` instead: the SDK exposes a plain HTTPS * `mobileContinuationUrl` for the UI to render as a primary "Open Vana" link, * never launching it automatically, and keeps polling in memory. */ export type DirectConnectState = { type: "idle"; } | { type: "creating"; } | { type: "awaiting_approval"; request: AccessRequest; /** * `true` when the popup was blocked. The UI should render the universal * HTTPS `request.approvalUrl` as a manual "Open approval" link. */ popupBlocked: boolean; } | { type: "ready_to_open"; request: AccessRequest; /** * Validated HTTPS continuation URL the mobile UI renders as the primary * "Open Vana" tap. Polling continues while it is shown; its embedded * ticket may rotate to a fresh URL between polls. */ mobileContinuationUrl: string; } | { type: "reading"; request: AccessRequest; } | { type: "done"; result: ApprovedDataResult; } | { type: "error"; error: Error; }; /** Whether an explicit read retry reused consent or started fresh approval. */ export type DirectConnectRetryOutcome = "retried_existing_grant" | "fresh_approval_required"; /** The store returned by {@link createDirectConnectFlow}. */ export interface DirectConnectFlow { /** Current state. */ getState(): DirectConnectState; /** Subscribe to state changes; returns an unsubscribe function. */ subscribe(listener: () => void): () => void; /** Begin the flow. No-op if already running. */ start(): Promise; /** * Retry a failed read, reusing a still-live approved request when possible. * * @remarks * This explicit path avoids the observed double-approval symptom where * "Try that again" minted a new request after a transient read failure. * The return value tells callers whether existing consent was reused or a * fresh approval was required. */ retryRead(): Promise; /** Reset to `idle` and stop any in-flight polling. */ reset(): void; } /** * Create a connect-flow store. * * @param transports - Backend transports (`createRequest`, `getStatus`, `readResult`). * @param options - Polling/timeout tunables and injectable side effects. * @returns A {@link DirectConnectFlow} store. */ export declare function createDirectConnectFlow(transports: DirectConnectTransports, options?: DirectConnectOptions): DirectConnectFlow;