Path: blob/main/src/publish/posit-connect-cloud/api/index.ts
12922 views
/*1* index.ts2*3* Copyright (C) 2026 Posit Software, PBC4*5* API client for Posit Connect Cloud. Handles OAuth Device Code flow6* authentication and all Connect Cloud API operations.7*/89import { debug } from "../../../deno_ral/log.ts";10import { sleep } from "../../../core/wait.ts";11import { quartoConfig } from "../../../core/quarto.ts";12import { ApiError } from "../../types.ts";1314import {15Account,16Content,17DeviceAuthResponse,18EnvironmentConfig,19PaginatedResponse,20PositConnectCloudEnvironment,21PositConnectCloudToken,22Revision,23TokenResponse,24User,25} from "./types.ts";2627import {28readAccessTokens,29writeAccessToken,30writeAccessTokens,31} from "../../common/account.ts";3233const kProviderName = "posit-connect-cloud";3435// Connect Cloud OAuth scope granting access to the Connect Cloud (Vivid) API36const kOAuthScope = "vivid";3738const publishDebug = (msg: string) =>39debug(`[publish][posit-connect-cloud] ${msg}`);40export { publishDebug as positConnectCloudDebug };4142const kEnvironments: Record<PositConnectCloudEnvironment, EnvironmentConfig> = {43production: {44authHost: "login.posit.cloud",45apiHost: "api.connect.posit.cloud",46uiHost: "connect.posit.cloud",47clientId: "quarto-cli",48},49staging: {50authHost: "login.staging.posit.cloud",51apiHost: "api.staging.connect.posit.cloud",52uiHost: "staging.connect.posit.cloud",53clientId: "quarto-cli-staging",54},55development: {56authHost: "login.staging.posit.cloud",57apiHost: "api.dev.connect.posit.cloud",58uiHost: "dev.connect.posit.cloud",59clientId: "quarto-cli-staging",60},61};6263export function getEnvironment(): PositConnectCloudEnvironment {64const env = Deno.env.get("POSIT_CONNECT_CLOUD_ENVIRONMENT");65if (env === "staging" || env === "development" || env === "production") {66return env;67}68return "production";69}7071export function getEnvironmentConfig(): EnvironmentConfig {72return kEnvironments[getEnvironment()];73}7475// Proactive refresh threshold: 5 minutes before expiry76const kRefreshThresholdMs = 5 * 60 * 1000;7778export class PositConnectCloudClient {79private env_: EnvironmentConfig;80private accessToken_: string;81private storedToken_: PositConnectCloudToken | undefined;8283constructor(84accessToken: string,85storedToken?: PositConnectCloudToken,86) {87this.env_ = getEnvironmentConfig();88this.accessToken_ = accessToken;89this.storedToken_ = storedToken;90publishDebug(91`Client created for ${this.env_.apiHost}`,92);93}9495// --- API Methods ---9697public async getUser(): Promise<User> {98return await this.apiGet<User>("users/me");99}100101// Single-page fetch is sufficient: Connect Cloud limits accounts per user102// (typically 1-3), and has_user_role=true further restricts the result set.103public async listAccounts(): Promise<Account[]> {104const response = await this.apiGet<PaginatedResponse<Account>>(105"accounts?has_user_role=true",106);107return response.data;108}109110public async createContent(111accountId: string,112title: string,113primaryFile: string,114): Promise<Content> {115const body = {116account_id: accountId,117title,118next_revision: {119source_type: "bundle",120content_type: "static",121app_mode: "static",122primary_file: primaryFile,123},124secrets: [],125};126publishDebug(127`POST /contents (title: ${title}, account: ${accountId})`,128);129return await this.apiPost<Content>("contents", body);130}131132public async getContent(contentId: string): Promise<Content> {133return await this.apiGet<Content>(`contents/${contentId}`);134}135136public async updateContent(137contentId: string,138primaryFile: string,139): Promise<Content> {140const body = {141secrets: [],142revision_overrides: {143primary_file: primaryFile,144app_mode: "static",145},146};147publishDebug(148`PATCH /contents/${contentId}?new_bundle=true`,149);150return await this.apiFetch<Content>(151"PATCH",152`contents/${contentId}?new_bundle=true`,153body,154);155}156157public async uploadBundle(uploadUrl: string, bundleData: Uint8Array) {158publishDebug(159`Uploading bundle (${bundleData.length} bytes)`,160);161const response = await fetch(uploadUrl, {162method: "POST",163headers: {164"Content-Type": "application/gzip",165},166body: bundleData,167});168if (!response.ok) {169const text = await response.text().catch(() => "");170throw new ApiError(171response.status,172response.statusText,173text || undefined,174);175}176publishDebug("Bundle uploaded successfully");177}178179public async publishContent(contentId: string) {180publishDebug(`POST /contents/${contentId}/publish`);181const url = this.buildUrl_(`contents/${contentId}/publish`);182const response = await this.fetchWithRetry_("POST", url, {183"Accept": "application/json",184}, undefined);185// Drain response body to release the connection (no useful payload)186await response.arrayBuffer();187}188189public async getRevision(revisionId: string): Promise<Revision> {190return await this.apiGet<Revision>(`revisions/${revisionId}`);191}192193private buildUrl_(path: string): string {194return `https://${this.env_.apiHost}/v1/${path}`;195}196197public contentUrl(accountName: string, contentId: string): string {198return `https://${this.env_.uiHost}/${accountName}/content/${contentId}`;199}200201public accountCreationUrl(): string {202return `https://${this.env_.uiHost}/account/done?utm_source=quarto-cli`;203}204205// --- Token Refresh ---206207private async ensureValidToken_() {208if (!this.storedToken_) return;209if (this.storedToken_.expiresAt === 0) return; // Unknown expiry (env tokens)210const now = Date.now();211if (now >= this.storedToken_.expiresAt - kRefreshThresholdMs) {212publishDebug(213"Token refresh: proactive (expires soon)",214);215await this.tryRefreshToken_();216}217}218219private async tryRefreshToken_(): Promise<boolean> {220if (!this.storedToken_?.refreshToken) return false;221try {222const tokenResponse = await refreshAccessToken(223this.env_,224this.storedToken_.refreshToken,225);226this.accessToken_ = tokenResponse.access_token;227this.storedToken_ = {228...this.storedToken_,229accessToken: tokenResponse.access_token,230refreshToken: tokenResponse.refresh_token,231expiresAt: Date.now() + (tokenResponse.expires_in * 1000),232};233// Only persist to disk for real accounts (not env pseudo-tokens with empty accountId)234if (this.storedToken_.accountId) {235writeAccessToken(236kProviderName,237this.storedToken_,238(a, b) =>239a.accountId === b.accountId && a.environment === b.environment,240);241publishDebug("Token refreshed and persisted");242} else {243publishDebug("Token refreshed (in-memory only, env token)");244}245return true;246} catch (err) {247publishDebug(248`Token refresh failed: ${err}`,249);250return false;251}252}253254// --- HTTP primitives with token refresh ---255256private async apiGet<T>(path: string): Promise<T> {257return await this.apiFetch<T>("GET", path);258}259260private async apiPost<T>(261path: string,262body?: Record<string, unknown>,263): Promise<T> {264return await this.apiFetch<T>("POST", path, body);265}266267private async apiFetch<T>(268method: string,269path: string,270body?: Record<string, unknown>,271): Promise<T> {272const url = this.buildUrl_(path);273const headers: Record<string, string> = {274"Accept": "application/json",275};276if (body) {277headers["Content-Type"] = "application/json";278}279const response = await this.fetchWithRetry_(280method,281url,282headers,283body ? JSON.stringify(body) : undefined,284);285return await response.json() as T;286}287288private async fetchWithRetry_(289method: string,290url: string,291headers: Record<string, string>,292body?: string | Uint8Array,293): Promise<Response> {294await this.ensureValidToken_();295const buildHeaders = () => ({296...headers,297"Authorization": `Bearer ${this.accessToken_}`,298"User-Agent": `quarto-cli/${quartoConfig.version()}`,299});300301const response = await fetch(url, {302method,303headers: buildHeaders(),304body,305});306307if (response.ok) {308return response;309}310311// On 401, try refresh + retry once312if (response.status === 401 && await this.tryRefreshToken_()) {313await response.arrayBuffer();314publishDebug("Retrying after token refresh");315const retryResponse = await fetch(url, {316method,317headers: buildHeaders(),318body,319});320if (retryResponse.ok) {321return retryResponse;322}323const text = await retryResponse.text().catch(() => "");324throw new ApiError(325retryResponse.status,326retryResponse.statusText,327text || undefined,328);329}330331const description = await response.text().catch(() => undefined);332throw new ApiError(response.status, response.statusText, description);333}334}335336// --- OAuth Device Code Flow (standalone functions, used by authorizeToken) ---337338function postFormUrlEncoded(339url: string,340params: URLSearchParams,341): Promise<Response> {342return fetch(url, {343method: "POST",344headers: { "Content-Type": "application/x-www-form-urlencoded" },345body: params.toString(),346});347}348349export async function initiateDeviceAuth(350env: EnvironmentConfig,351): Promise<DeviceAuthResponse> {352const params = new URLSearchParams({353scope: kOAuthScope,354client_id: env.clientId,355});356publishDebug(357`OAuth: initiating device authorization (client_id: ${env.clientId})`,358);359const response = await postFormUrlEncoded(360`https://${env.authHost}/oauth/device/authorize`,361params,362);363if (!response.ok) {364const text = await response.text().catch(() => "");365throw new ApiError(366response.status,367response.statusText,368text || undefined,369);370}371return await response.json() as DeviceAuthResponse;372}373374export async function pollForToken(375env: EnvironmentConfig,376deviceCode: string,377initialInterval: number,378expiresIn: number,379): Promise<TokenResponse> {380let interval = Math.max(initialInterval, 5);381const params = new URLSearchParams({382scope: kOAuthScope,383client_id: env.clientId,384grant_type: "urn:ietf:params:oauth:grant-type:device_code",385device_code: deviceCode,386});387const url = `https://${env.authHost}/oauth/token`;388const startTime = Date.now();389const timeoutMs = expiresIn * 1000;390391while (true) {392if (Date.now() - startTime > timeoutMs) {393throw new Error(394"Authorization timed out. The verification code has expired. Please try again.",395);396}397398publishDebug(399`OAuth: polling for token (interval: ${interval}s)`,400);401await sleep(interval * 1000);402403const response = await postFormUrlEncoded(url, params);404405if (response.ok) {406publishDebug("OAuth: token received");407return await response.json() as TokenResponse;408}409410// Parse error defensively: try JSON .error field, fall back to plain string411const body = await response.text();412let errorCode: string;413try {414const parsed = JSON.parse(body);415errorCode = parsed.error || body;416} catch {417errorCode = body.trim();418}419420// Error codes per RFC 8628 Section 3.5 (OAuth 2.0 Device Authorization Grant)421switch (errorCode) {422case "authorization_pending":423// Keep polling424break;425case "slow_down":426interval += 5;427publishDebug(428`OAuth: slow_down, new interval: ${interval}s`,429);430break;431case "expired_token":432throw new Error(433"Authorization timed out. The verification code has expired. Please try again.",434);435case "access_denied":436throw new Error(437"Authorization was denied. Please try again and approve the request.",438);439default:440throw new ApiError(441response.status,442response.statusText,443`Unexpected OAuth error: ${errorCode}`,444);445}446}447}448449export async function refreshAccessToken(450env: EnvironmentConfig,451refreshToken: string,452): Promise<TokenResponse> {453const params = new URLSearchParams({454scope: kOAuthScope,455client_id: env.clientId,456grant_type: "refresh_token",457refresh_token: refreshToken,458});459publishDebug("Refreshing access token");460const response = await postFormUrlEncoded(461`https://${env.authHost}/oauth/token`,462params,463);464if (!response.ok) {465const text = await response.text().catch(() => "");466throw new ApiError(467response.status,468response.statusText,469text || undefined,470);471}472return await response.json() as TokenResponse;473}474475// --- Token storage helpers ---476477export function readStoredTokens(): PositConnectCloudToken[] {478return readAccessTokens<PositConnectCloudToken>(kProviderName) || [];479}480481export function writeStoredTokens(tokens: PositConnectCloudToken[]) {482writeAccessTokens(kProviderName, tokens);483}484485export function writeStoredToken(token: PositConnectCloudToken) {486writeAccessToken(487kProviderName,488token,489(a, b) => a.accountId === b.accountId && a.environment === b.environment,490);491}492493494