/** * Host-side single-flight registry for the settings pane's one-click Feishu * bot creation. * * The pane cannot drive `registerApp` directly, for three reasons: * * 1. The flow prints a link and then *waits for a human* for up to ~10 minutes. * A single RPC call cannot span both moments — the link must reach the pane * within a second of the click, the result minutes later. * 2. A long call is guaranteed to be aborted: the settings HTTP handler aborts * on `res.on("close")`, and the device-authorization link is single-use and * single-person, so propagating that abort burns the user's only chance. * 3. Creation is only one of five steps (create → mirror → store → subscribe → * enable + apply); the other four need host-side services the pane has no * access to, and must not be re-implemented client-side. * * So `start()` kicks the flow off, answers immediately, and the pane polls * `status()` until a terminal phase. Everything the flow does lives here. * * @module dsh-connect/settings/feishu-onboarding */ import type { Language } from "../i18n.js"; import type { FeishuCredentials } from "../channels/feishu/onboard.js"; /** Lifecycle of one one-click attempt. */ export type OnboardingPhase = "idle" | "waiting" | "done" | "cancelled" | "failed"; /** * Outcome of the event-subscription step. Reported, never assumed: Feishu's own * API doc says the config PATCH may not accept an app created through the bot * assistant flow, and that a `200` only means the *request* was accepted — * most config changes need a release + review before they reach the wire. */ export interface SubscriptionStep { attempted: boolean; status: "not-attempted" | "applied" | "failed" | "skipped"; /** The API's own `code`/`msg`, passed through verbatim when it failed. */ reason?: string; /** The user still has to do this by hand in the developer console. */ needsManualAction: boolean; } /** Everything the pane needs to report — each field a separate true fact. */ export interface OnboardingOutcome { /** The app really was created in the tenant. */ created: boolean; /** Never carries the app secret. */ appId?: string; /** `credentialStore.save()` resolved: the secret is in the DSH credential store. */ credentialsStored: boolean; /** The legacy plaintext JSON mirror was written. */ legacyMirrorWritten: boolean; /** Whether the running adapter actually picked up the new credentials. */ applied: "pending" | "yes" | "no"; subscription: SubscriptionStep; /** The enable + config write was requested of the settings service. */ enableRequested: boolean; /** Flow-level reason: the SDK error code, or the thrown message. */ reason?: string; } /** The device-authorization link, as handed to the pane. */ export interface OnboardingLink { url: string; expiresInSeconds: number; expiresAt: number; } /** What `start`/`status`/`cancel` return. */ export interface OnboardingStatus { phase: OnboardingPhase; channel: "feishu"; link?: OnboardingLink; outcome?: OnboardingOutcome; } /** The onboarding flow seam — the real one, or a test double. */ export type OnboardFlow = typeof import("../channels/feishu/onboard.js").onboardFeishu; /** * Everything the registry touches, injected. All of it is a host-side service * or a pure function, which is what makes the whole flow unit-testable without * a network round trip or a ten-minute wait. */ export interface FeishuOnboardingDeps { /** * The flow itself. Same seam as `FeishuRegisterOptions.onboard` * (`../channels/feishu/index.ts`), so one fake serves both call sites. */ onboard: OnboardFlow; /** * The DSH credential store — the only durable home for the secret. Optional: * without it, the legacy mirror and the in-memory instance still come up, and * `credentialsStored` says `false` rather than pretending. */ credentialStore?: { save(channel: "feishu", values: Record): Promise; }; /** Writes the legacy plaintext mirror; returns whether it succeeded. */ legacySave: (credentials: FeishuCredentials) => boolean; /** * PATCH the app's event subscription to long-connection mode. Best effort — * see {@link SubscriptionStep}; a rejection is reported, not retried. * * Takes the secret because the PATCH authenticates *as the app*, and this * registry is the only place that ever holds it in memory. */ applySubscription?: (credentials: FeishuCredentials) => Promise<{ status: "applied" | "failed"; reason?: string; }>; /** * Write the *complete* `dsh-connect` section with `feishu` in `channels` and * `feishu.transport === "websocket"`, through the same path a pane save takes * (a partial write here would be a deletion — see the 0.9.2 rule). Rejecting * means the channel was not enabled. */ requestEnable: () => Promise; /** * Re-apply the running channels so the just-stored credentials take effect * now rather than at the next restart. Rejecting means `applied: "no"`. * * The reason for such a failure is deliberately *not* copied into the * outcome: the pane polls `settings.status` for the same snapshot, whose * `channelErrors` map already carries the adapter's own message, and a second * copy here could disagree with it. */ reconcile: () => Promise; language?: Language; logger?: { warn?: (...args: unknown[]) => void; }; /** Injectable for the expiry test, which otherwise waits 10 minutes. */ now?: () => number; /** Injectable so a test can fire (or count) the expiry timer at will. */ setTimer?: (callback: () => void, ms: number) => { cancel(): void; }; } /** * Compose the config write that enables Feishu, from the section in force now. * * The *complete* declared section goes back, never a fragment: this feeds the * same `save` a pane save uses, and a partial write there is a deletion (the * 0.9.2 rule) — it would drop every other channel the user had enabled and every * other `feishu` field. `transport` is pinned to `"websocket"` because that is * what the app the flow just created speaks; the webhook transport would need a * public URL and a verification token nobody configured. * * Pure and exported on purpose. The alternative was an inline closure inside * `apply`, whose only route to a test is a live `registerApp` against Feishu — * so the completeness rule would have had no test at all. */ export declare function enableFeishuConfig(section: Record, allChannels: readonly string[]): Record; /** The pane-facing surface. */ export interface FeishuOnboardingRegistry { start(channel: string): Promise; status(): Promise; cancel(): Promise; } /** * Build the registry. One instance per plugin, so the single-flight state is * per-process — exactly the scope the device-authorization link has. */ export declare function createFeishuOnboarding(deps: FeishuOnboardingDeps): FeishuOnboardingRegistry; //# sourceMappingURL=feishu-onboarding.d.ts.map