import * as v from "@badrap/valita"; import { HTTPError, APIError, Client, type ClientConfig } from "./client.ts"; import { Kv } from "./kv.ts"; import type * as KvTypes from "./kv.ts"; export { HTTPError, APIError }; function sleep(delay: number): Promise { return new Promise((resolve) => { setTimeout(() => { resolve(); }, delay); }); } export class UpdateFailed extends Error { constructor(cause: APIError) { super("installation update failed", { cause }); this.name = "UpdateFailed"; } } type InstallationStatus = "active" | "paused" | "uninstalled"; const InstallationStatus: v.Type = v.union( v.literal("active"), v.literal("paused"), v.literal("uninstalled"), ); type InstallationOwner = | { type: "team"; name: string } | { type: "user"; email: string }; const InstallationOwner: v.Type = v.union( v.object({ type: v.literal("team"), name: v.string() }), v.object({ type: v.literal("user"), email: v.string() }), ); export type Installation> = { id: string; state: State; owner?: InstallationOwner; status: InstallationStatus; }; export type Asset = Readonly<{ type: "ip" | "email" | "domain" | "opaque"; value: string; key?: string; props?: Record; }>; export type Event = Readonly<{ type: "ip" | "email" | "domain" | "opaque"; value: string; key?: string; props?: Record; }>; type MaybePromise = Promise | T; interface Config extends ClientConfig { stateType?: v.Type; } export class API< InstallationState extends Record = Record, > { readonly #client: Client; readonly #stateType: v.Type; readonly kv: Kv; constructor(config: Config) { this.#client = new Client(config); this.#stateType = config.stateType ?? (v.record() as v.Type); this.kv = new Kv(this.#client); } async checkAuthToken( token: string, ): Promise<{ installationId: string; sessionId: string; expiresAt: number }> { const result = await this.#client.request({ method: "POST", path: ["token"], json: { token }, responseType: v.object({ installation_id: v.string(), session_id: v.string(), expires_at: v.number(), }), }); return { installationId: result.installation_id, sessionId: result.session_id, expiresAt: result.expires_at, }; } async *listInstallations(options?: { status?: InstallationStatus | InstallationStatus[]; }): AsyncIterable<{ id: string; owner?: { type: "team"; name: string } | { type: "user"; email: string }; status: InstallationStatus; }> { const { status } = options ?? {}; const list = await this.#client.request({ method: "GET", path: ["installations"], responseType: v.array( v.object({ id: v.string(), owner: InstallationOwner.optional(), status: InstallationStatus, }), ), }); const statusSet = status === undefined ? undefined : typeof status === "string" ? new Set([status]) : new Set(status); for (const installation of list) { if (!statusSet || statusSet.has(installation.status)) { yield installation; } } } async createInstallationCallback( installationId: string, sessionId: string, callback: { action?: unknown; clientState?: Record; } = {}, ): Promise { return this.#client.request({ method: "POST", path: ["installations", installationId, "callbacks"], json: { session_id: sessionId, action: callback.action, client_state: callback.clientState, }, responseType: v.object({ url: v.string() }).map((r) => r.url), }); } async getInstallation( installationId: string, ): Promise> { return this.#client.request({ method: "GET", path: ["installations", installationId], responseType: v.object({ id: v.string(), state: this.#stateType, owner: InstallationOwner.optional(), status: InstallationStatus, }), }); } async updateInstallation( installationId: string, callback: ( installation: Installation, ) => MaybePromise< { assets?: Asset[]; state?: InstallationState } | undefined >, options?: { retry?: false | number; }, ): Promise> { const { retry = 10 } = options ?? {}; const maxRetries = retry === false ? 0 : retry; const jitterFactor = 0.1; const backoffStep = 100; const maxBackoff = 1000; for (let attempt = 0; ; attempt++) { const { body, etag } = await this.#client.requestWithEtag({ method: "GET", path: ["installations", installationId], responseType: v.object({ id: v.string(), state: this.#stateType, owner: InstallationOwner.optional(), status: InstallationStatus, }), }); const patch = await callback(structuredClone(body)); if (!patch) { return body; } const { assets, state } = patch; if (!assets && !state) { return body; } try { await this.#client.request({ method: "PATCH", path: ["installations", installationId], headers: { "if-match": etag ?? "*" }, json: patch, }); } catch (err) { if (err instanceof APIError && err.statusCode === 412) { if (attempt >= maxRetries) { throw new UpdateFailed(err); } const backoff = Math.min(backoffStep * 2 ** attempt, maxBackoff); await sleep(backoff * (1 + Math.random() * jitterFactor)); continue; } throw err; } return { ...body, ...patch }; } } async removeInstallation(installationId: string): Promise { await this.#client.request({ method: "DELETE", path: ["installations", installationId], }); } async *listOwnerAssets( installationId: string, ): AsyncIterable<{ type: "ip" | "email" | "domain"; value: string }> { yield* await this.#client.request({ method: "GET", path: ["installations", installationId, "owner", "assets"], responseType: v.array( v.object({ type: v.union( v.literal("ip"), v.literal("email"), v.literal("domain"), ), value: v.string(), }), ), }); } async ensureFeed( name: string, config?: { title?: string; summaryTemplate?: unknown; detailsTemplate?: unknown; }, ): Promise { try { await this.#client.request({ method: "POST", path: ["feeds"], json: { name, title: config?.title, summary_template: config?.summaryTemplate, details_template: config?.detailsTemplate, }, }); } catch (err: unknown) { if (!(err instanceof HTTPError) || err.statusCode !== 409) { throw err; } await this.#client.request({ method: "PATCH", path: ["feeds", name], headers: { "if-match": "*" }, json: { title: config?.title, summary_template: config?.summaryTemplate, details_template: config?.detailsTemplate, }, }); } } async feedEventsForInstallation( installationId: string, feedName: string, events: Event[], ): Promise { await this.#client.request({ method: "POST", path: ["installations", installationId, "feeds", feedName, "events"], json: events, }); } } export declare namespace API { export type Kv = KvTypes.Kv; export type KvKey = KvTypes.KvKey; export type KvEntry = KvTypes.KvEntry; export type KvEntryMaybe = KvTypes.KvEntryMaybe; export type KvCheck = KvTypes.KvCheck; export type KvMutation = KvTypes.KvMutation; export type KvCommitResult = KvTypes.KvCommitResult; export type KvCommitError = KvTypes.KvCommitError; export type KvListSelector = KvTypes.KvListSelector; export type KvListOptions = KvTypes.KvListOptions; export type KvListIterator = KvTypes.KvListIterator; export type KvAtomicOperation = KvTypes.KvAtomicOperation; }