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