@amigo-ai/platform-sdk 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -37,14 +37,21 @@ __export(index_exports, {
37
37
  ConfigurationError: () => ConfigurationError,
38
38
  ConflictError: () => ConflictError,
39
39
  DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
40
+ DeviceCodeDeniedError: () => DeviceCodeDeniedError,
41
+ DeviceCodeExpiredError: () => DeviceCodeExpiredError,
42
+ FileTokenStorage: () => FileTokenStorage,
43
+ LoginCancelledError: () => LoginCancelledError,
44
+ MemoryTokenStorage: () => MemoryTokenStorage,
40
45
  NetworkError: () => NetworkError,
41
46
  NotFoundError: () => NotFoundError,
42
47
  ParseError: () => ParseError,
43
48
  PermissionError: () => PermissionError,
44
49
  RateLimitError: () => RateLimitError,
50
+ RefreshTokenExpiredError: () => RefreshTokenExpiredError,
45
51
  RequestTimeoutError: () => RequestTimeoutError,
46
52
  ServerError: () => ServerError,
47
53
  ServiceUnavailableError: () => ServiceUnavailableError,
54
+ TokenManager: () => TokenManager,
48
55
  ValidationError: () => ValidationError,
49
56
  WebhookVerificationError: () => WebhookVerificationError,
50
57
  actionId: () => actionId,
@@ -57,6 +64,9 @@ __export(index_exports, {
57
64
  entityId: () => entityId,
58
65
  eventId: () => eventId,
59
66
  extractRequestId: () => extractRequestId,
67
+ formatDeviceCodeInstructions: () => formatDeviceCodeInstructions,
68
+ formatDeviceCodeLink: () => formatDeviceCodeLink,
69
+ formatWorkspaceList: () => formatWorkspaceList,
60
70
  functionId: () => functionId,
61
71
  integrationId: () => integrationId,
62
72
  isAmigoError: () => isAmigoError,
@@ -64,6 +74,8 @@ __export(index_exports, {
64
74
  isNotFoundError: () => isNotFoundError,
65
75
  isRateLimitError: () => isRateLimitError,
66
76
  isRequestTimeoutError: () => isRequestTimeoutError,
77
+ loginWithDeviceCode: () => loginWithDeviceCode,
78
+ openBrowser: () => openBrowser,
67
79
  paginate: () => paginate,
68
80
  parseRateLimitHeaders: () => parseRateLimitHeaders,
69
81
  parseWebhookEvent: () => parseWebhookEvent,
@@ -3143,6 +3155,442 @@ function toCryptoBuffer(bytes) {
3143
3155
  return copy.buffer;
3144
3156
  }
3145
3157
 
3158
+ // src/core/device-code.ts
3159
+ var DeviceCodeExpiredError = class extends AmigoError {
3160
+ constructor(message = "Device code expired. Please restart the login flow.") {
3161
+ super(message, { errorCode: "device_code_expired" });
3162
+ }
3163
+ };
3164
+ var DeviceCodeDeniedError = class extends AmigoError {
3165
+ constructor(message = "Authorization request was denied.") {
3166
+ super(message, { errorCode: "device_code_denied" });
3167
+ }
3168
+ };
3169
+ var RefreshTokenExpiredError = class extends AuthenticationError {
3170
+ constructor(message = "Refresh token expired. Please log in again.") {
3171
+ super(message, { errorCode: "refresh_token_expired" });
3172
+ }
3173
+ };
3174
+ var LoginCancelledError = class extends AmigoError {
3175
+ constructor() {
3176
+ super("Login cancelled", { errorCode: "login_cancelled" });
3177
+ }
3178
+ };
3179
+ var DEFAULT_IDENTITY_URL = "https://identity.platform.amigo.ai";
3180
+ async function identityPost(baseUrl, path, body, fetchFn) {
3181
+ try {
3182
+ return await fetchFn(`${baseUrl}${path}`, {
3183
+ method: "POST",
3184
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
3185
+ body: body.toString(),
3186
+ redirect: "manual"
3187
+ });
3188
+ } catch (err) {
3189
+ throw new NetworkError("Network error contacting identity service", err);
3190
+ }
3191
+ }
3192
+ async function requestDeviceCode(baseUrl, params, fetchFn) {
3193
+ const body = new URLSearchParams();
3194
+ if (params.clientDescription) body.set("client_description", params.clientDescription);
3195
+ if (params.scope) body.set("scope", params.scope);
3196
+ const res = await identityPost(baseUrl, "/device/code", body, fetchFn);
3197
+ if (res.status === 429) {
3198
+ const retryAfter = parseInt(res.headers.get("Retry-After") ?? "", 10);
3199
+ throw new RateLimitError("Rate limited", { retryAfter: isNaN(retryAfter) ? void 0 : retryAfter });
3200
+ }
3201
+ if (!res.ok) {
3202
+ const err = await res.json().catch(() => ({}));
3203
+ throw new AmigoError(err.error_description ?? `Identity error (${res.status})`, {
3204
+ statusCode: res.status,
3205
+ errorCode: err.error
3206
+ });
3207
+ }
3208
+ return await res.json();
3209
+ }
3210
+ async function pollDeviceCode(baseUrl, deviceCode, scope, workspaceId2, fetchFn) {
3211
+ const body = new URLSearchParams({ grant_type: "device_code", device_code: deviceCode });
3212
+ if (scope) body.set("scope", scope);
3213
+ if (workspaceId2) body.set("workspace_id", workspaceId2);
3214
+ const res = await identityPost(baseUrl, "/token", body, fetchFn);
3215
+ if (res.status === 300)
3216
+ return { type: "multi_workspace", data: await res.json() };
3217
+ if (res.status === 200) return { type: "token", data: await res.json() };
3218
+ if (res.status === 400) {
3219
+ const err = await res.json().catch(() => ({ error: "unknown" }));
3220
+ if (err.error === "authorization_pending") return { type: "pending" };
3221
+ if (err.error === "slow_down") return { type: "slow_down" };
3222
+ throw new AmigoError(err.error_description ?? err.error ?? `Identity error (400)`, {
3223
+ statusCode: 400,
3224
+ errorCode: err.error
3225
+ });
3226
+ }
3227
+ throw new AmigoError(`Identity error (${res.status})`, { statusCode: res.status });
3228
+ }
3229
+ async function doRefreshToken(baseUrl, params, fetchFn) {
3230
+ const body = new URLSearchParams({
3231
+ grant_type: "refresh_token",
3232
+ refresh_token: params.refreshToken
3233
+ });
3234
+ if (params.workspaceId) body.set("workspace_id", params.workspaceId);
3235
+ if (params.scope) body.set("scope", params.scope);
3236
+ const res = await identityPost(baseUrl, "/token", body, fetchFn);
3237
+ if (!res.ok) {
3238
+ const err = await res.json().catch(() => ({}));
3239
+ throw new AmigoError(err.error_description ?? `Identity error (${res.status})`, {
3240
+ statusCode: res.status,
3241
+ errorCode: err.error
3242
+ });
3243
+ }
3244
+ return await res.json();
3245
+ }
3246
+ function decodeJwtPayload(jwt) {
3247
+ try {
3248
+ const parts = jwt.split(".");
3249
+ if (parts.length !== 3 || !parts[1]) return null;
3250
+ const payload = Buffer.from(parts[1], "base64url").toString();
3251
+ return JSON.parse(payload);
3252
+ } catch {
3253
+ return null;
3254
+ }
3255
+ }
3256
+ function sleep2(ms, signal) {
3257
+ return new Promise((resolve, reject) => {
3258
+ if (signal?.aborted) {
3259
+ reject(new LoginCancelledError());
3260
+ return;
3261
+ }
3262
+ const timer = setTimeout(resolve, ms);
3263
+ signal?.addEventListener(
3264
+ "abort",
3265
+ () => {
3266
+ clearTimeout(timer);
3267
+ reject(new LoginCancelledError());
3268
+ },
3269
+ { once: true }
3270
+ );
3271
+ });
3272
+ }
3273
+ function toAuthResult(token, workspaceIdOverride) {
3274
+ const claims = decodeJwtPayload(token.access_token);
3275
+ const workspaceId2 = workspaceIdOverride ?? claims?.workspace_id ?? "";
3276
+ if (!workspaceId2) {
3277
+ throw new AmigoError("Token does not contain a workspace_id claim", {
3278
+ errorCode: "missing_workspace"
3279
+ });
3280
+ }
3281
+ return {
3282
+ accessToken: token.access_token,
3283
+ refreshToken: token.refresh_token ?? "",
3284
+ workspaceId: workspaceId2,
3285
+ expiresAt: claims?.exp ?? (token.expires_in ? Math.floor(Date.now() / 1e3) + token.expires_in : Math.floor(Date.now() / 1e3) + 900),
3286
+ scope: token.scope
3287
+ };
3288
+ }
3289
+ async function fetchWorkspaces(baseUrl, accessToken, fetchFn) {
3290
+ for (const path of ["/self/profile", "/self"]) {
3291
+ const res = await fetchFn(`${baseUrl}${path}`, {
3292
+ headers: { Authorization: `Bearer ${accessToken}` }
3293
+ });
3294
+ if (res.ok) {
3295
+ const data = await res.json();
3296
+ return (data.workspaces ?? []).map((ws) => ({
3297
+ workspace_id: ws.workspace_id,
3298
+ role: ws.role,
3299
+ name: ws.name
3300
+ }));
3301
+ }
3302
+ if (res.status !== 404) {
3303
+ throw new AmigoError(`Failed to fetch workspaces (${res.status})`, { statusCode: res.status });
3304
+ }
3305
+ }
3306
+ return [];
3307
+ }
3308
+ async function resolveWorkspaceFromBootstrap(baseUrl, token, options, fetchFn) {
3309
+ if (!token.refresh_token) {
3310
+ throw new AmigoError("Bootstrap token missing refresh_token", { errorCode: "server_error" });
3311
+ }
3312
+ const workspaces = await fetchWorkspaces(baseUrl, token.access_token, fetchFn);
3313
+ if (workspaces.length === 0) {
3314
+ throw new AmigoError(
3315
+ "No workspace memberships found. Create a workspace or request an invitation.",
3316
+ { errorCode: "no_workspaces" }
3317
+ );
3318
+ }
3319
+ if (workspaces.length === 1 && workspaces[0]) {
3320
+ const scoped2 = await doRefreshToken(
3321
+ baseUrl,
3322
+ { refreshToken: token.refresh_token, workspaceId: workspaces[0].workspace_id, scope: options.scope },
3323
+ fetchFn
3324
+ );
3325
+ return toAuthResult(scoped2, workspaces[0].workspace_id);
3326
+ }
3327
+ const workspaceId2 = await options.onWorkspaceRequired(workspaces);
3328
+ const scoped = await doRefreshToken(
3329
+ baseUrl,
3330
+ { refreshToken: token.refresh_token, workspaceId: workspaceId2, scope: options.scope },
3331
+ fetchFn
3332
+ );
3333
+ return toAuthResult(scoped, workspaceId2);
3334
+ }
3335
+ async function resolveWorkspaceFromMulti(baseUrl, multi, options, fetchFn) {
3336
+ if (!multi.refresh_token) {
3337
+ throw new AmigoError("Multi-workspace response missing refresh_token", { errorCode: "server_error" });
3338
+ }
3339
+ const workspaceId2 = await options.onWorkspaceRequired(multi.workspaces);
3340
+ const scoped = await doRefreshToken(
3341
+ baseUrl,
3342
+ { refreshToken: multi.refresh_token, workspaceId: workspaceId2, scope: options.scope },
3343
+ fetchFn
3344
+ );
3345
+ return toAuthResult(scoped, workspaceId2);
3346
+ }
3347
+ async function loginWithDeviceCode(options) {
3348
+ const baseUrl = (options.identityBaseUrl ?? DEFAULT_IDENTITY_URL).replace(/\/+$/, "");
3349
+ const fetchFn = options.fetch ?? globalThis.fetch;
3350
+ const issuance = await requestDeviceCode(
3351
+ baseUrl,
3352
+ { clientDescription: options.clientDescription, scope: options.scope },
3353
+ fetchFn
3354
+ );
3355
+ await options.onCode(issuance);
3356
+ let interval = issuance.interval * 1e3;
3357
+ const deadline = Date.now() + issuance.expires_in * 1e3;
3358
+ while (Date.now() < deadline) {
3359
+ if (options.signal?.aborted) throw new LoginCancelledError();
3360
+ await sleep2(interval, options.signal);
3361
+ if (options.signal?.aborted) throw new LoginCancelledError();
3362
+ options.onStatus?.("polling");
3363
+ try {
3364
+ const result = await pollDeviceCode(baseUrl, issuance.device_code, options.scope, options.workspaceId, fetchFn);
3365
+ if (result.type === "pending") {
3366
+ options.onStatus?.("authorization_pending");
3367
+ continue;
3368
+ }
3369
+ if (result.type === "slow_down") {
3370
+ interval += 5e3;
3371
+ options.onStatus?.("slow_down");
3372
+ continue;
3373
+ }
3374
+ options.onStatus?.("approved");
3375
+ if (result.type === "token") {
3376
+ const claims = decodeJwtPayload(result.data.access_token);
3377
+ const isBootstrap = claims?.workspace_bootstrap || !claims?.workspace_id;
3378
+ if (isBootstrap && result.data.refresh_token) {
3379
+ if (options.workspaceId) {
3380
+ const scoped = await doRefreshToken(
3381
+ baseUrl,
3382
+ { refreshToken: result.data.refresh_token, workspaceId: options.workspaceId, scope: options.scope },
3383
+ fetchFn
3384
+ );
3385
+ return toAuthResult(scoped, options.workspaceId);
3386
+ }
3387
+ return await resolveWorkspaceFromBootstrap(baseUrl, result.data, options, fetchFn);
3388
+ }
3389
+ return toAuthResult(result.data);
3390
+ }
3391
+ return await resolveWorkspaceFromMulti(baseUrl, result.data, options, fetchFn);
3392
+ } catch (err) {
3393
+ if (err instanceof AmigoError && err.errorCode === "expired_token") {
3394
+ options.onStatus?.("expired");
3395
+ throw new DeviceCodeExpiredError();
3396
+ }
3397
+ if (err instanceof AmigoError && err.errorCode === "access_denied") {
3398
+ options.onStatus?.("denied");
3399
+ throw new DeviceCodeDeniedError();
3400
+ }
3401
+ throw err;
3402
+ }
3403
+ }
3404
+ options.onStatus?.("expired");
3405
+ throw new DeviceCodeExpiredError();
3406
+ }
3407
+ var REFRESH_BUFFER_SECONDS = 60;
3408
+ var TokenManager = class {
3409
+ _storage;
3410
+ _baseUrl;
3411
+ _fetch;
3412
+ _cached = null;
3413
+ _refreshPromise = null;
3414
+ constructor(config = {}) {
3415
+ this._storage = config.storage ?? new FileTokenStorage();
3416
+ this._baseUrl = (config.identityBaseUrl ?? DEFAULT_IDENTITY_URL).replace(/\/+$/, "");
3417
+ this._fetch = config.fetch ?? globalThis.fetch;
3418
+ }
3419
+ async store(result) {
3420
+ const creds = {
3421
+ access_token: result.accessToken,
3422
+ refresh_token: result.refreshToken,
3423
+ workspace_id: result.workspaceId,
3424
+ expires_at: result.expiresAt,
3425
+ scope: result.scope
3426
+ };
3427
+ this._cached = creds;
3428
+ await this._storage.save(creds);
3429
+ }
3430
+ async getAccessToken() {
3431
+ const creds = await this._loadCached();
3432
+ if (!creds) return null;
3433
+ if (creds.expires_at - Math.floor(Date.now() / 1e3) >= REFRESH_BUFFER_SECONDS) {
3434
+ return { token: creds.access_token, workspaceId: creds.workspace_id };
3435
+ }
3436
+ const refreshed = await this._refresh(creds);
3437
+ return { token: refreshed.access_token, workspaceId: refreshed.workspace_id };
3438
+ }
3439
+ async hasCredentials() {
3440
+ return await this._loadCached() !== null;
3441
+ }
3442
+ async clear() {
3443
+ this._cached = null;
3444
+ this._refreshPromise = null;
3445
+ await this._storage.clear();
3446
+ }
3447
+ async _loadCached() {
3448
+ if (this._cached) return this._cached;
3449
+ const loaded = await this._storage.load();
3450
+ if (loaded) this._cached = loaded;
3451
+ return loaded;
3452
+ }
3453
+ async _refresh(current) {
3454
+ if (this._refreshPromise) return this._refreshPromise;
3455
+ this._refreshPromise = this._doRefresh(current).finally(() => {
3456
+ this._refreshPromise = null;
3457
+ });
3458
+ return this._refreshPromise;
3459
+ }
3460
+ async _doRefresh(current) {
3461
+ if (!current.refresh_token) throw new RefreshTokenExpiredError("No refresh token available");
3462
+ try {
3463
+ const response = await doRefreshToken(
3464
+ this._baseUrl,
3465
+ { refreshToken: current.refresh_token, workspaceId: current.workspace_id, scope: current.scope },
3466
+ this._fetch
3467
+ );
3468
+ const claims = decodeJwtPayload(response.access_token);
3469
+ const refreshed = {
3470
+ access_token: response.access_token,
3471
+ refresh_token: response.refresh_token ?? current.refresh_token,
3472
+ workspace_id: current.workspace_id,
3473
+ expires_at: claims?.exp ?? Math.floor(Date.now() / 1e3) + response.expires_in,
3474
+ scope: response.scope ?? current.scope
3475
+ };
3476
+ this._cached = refreshed;
3477
+ await this._storage.save(refreshed);
3478
+ return refreshed;
3479
+ } catch (err) {
3480
+ if (err instanceof AmigoError && (err.statusCode === 401 || err.errorCode === "invalid_grant")) {
3481
+ await this.clear();
3482
+ throw new RefreshTokenExpiredError();
3483
+ }
3484
+ throw err;
3485
+ }
3486
+ }
3487
+ };
3488
+ var FileTokenStorage = class {
3489
+ _explicitPath;
3490
+ _resolvedPath;
3491
+ constructor(filePath) {
3492
+ this._explicitPath = filePath;
3493
+ }
3494
+ async _filePath() {
3495
+ if (this._explicitPath) return this._explicitPath;
3496
+ if (this._resolvedPath) return this._resolvedPath;
3497
+ const os = await import("node:os");
3498
+ const path = await import("node:path");
3499
+ this._resolvedPath = path.join(os.homedir(), ".amigo", "credentials.json");
3500
+ return this._resolvedPath;
3501
+ }
3502
+ async load() {
3503
+ const fs = await import("node:fs/promises");
3504
+ const filePath = await this._filePath();
3505
+ try {
3506
+ const raw = await fs.readFile(filePath, "utf-8");
3507
+ const data = JSON.parse(raw);
3508
+ if (typeof data.access_token === "string" && typeof data.refresh_token === "string" && typeof data.workspace_id === "string" && typeof data.expires_at === "number") {
3509
+ return data;
3510
+ }
3511
+ return null;
3512
+ } catch (err) {
3513
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return null;
3514
+ throw err;
3515
+ }
3516
+ }
3517
+ async save(credentials) {
3518
+ const fs = await import("node:fs/promises");
3519
+ const path = await import("node:path");
3520
+ const filePath = await this._filePath();
3521
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 448 });
3522
+ await fs.writeFile(filePath, JSON.stringify(credentials, null, 2) + "\n", { mode: 384 });
3523
+ await fs.chmod(filePath, 384);
3524
+ }
3525
+ async clear() {
3526
+ const fs = await import("node:fs/promises");
3527
+ const filePath = await this._filePath();
3528
+ try {
3529
+ await fs.unlink(filePath);
3530
+ } catch (err) {
3531
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return;
3532
+ throw err;
3533
+ }
3534
+ }
3535
+ };
3536
+ var MemoryTokenStorage = class {
3537
+ _credentials = null;
3538
+ async load() {
3539
+ return this._credentials;
3540
+ }
3541
+ async save(credentials) {
3542
+ this._credentials = { ...credentials };
3543
+ }
3544
+ async clear() {
3545
+ this._credentials = null;
3546
+ }
3547
+ };
3548
+ function formatDeviceCodeInstructions(issuance) {
3549
+ return [
3550
+ "",
3551
+ " To sign in, open your browser and visit:",
3552
+ "",
3553
+ ` ${issuance.verification_uri}`,
3554
+ "",
3555
+ " Then enter this code:",
3556
+ "",
3557
+ ` ${issuance.user_code}`,
3558
+ "",
3559
+ ` This code expires in ${Math.floor(issuance.expires_in / 60)} minutes.`,
3560
+ ""
3561
+ ].join("\n");
3562
+ }
3563
+ function formatDeviceCodeLink(issuance) {
3564
+ return `\x1B]8;;${issuance.verification_uri_complete}\x07Open browser to approve\x1B]8;;\x07`;
3565
+ }
3566
+ async function openBrowser(url) {
3567
+ const { spawn } = await import("node:child_process");
3568
+ const openers = {
3569
+ darwin: { cmd: "open", args: [url] },
3570
+ win32: { cmd: "cmd", args: ["/c", "start", "", url] },
3571
+ linux: { cmd: "xdg-open", args: [url] }
3572
+ };
3573
+ const opener = openers[process.platform];
3574
+ if (!opener) return false;
3575
+ return new Promise((resolve) => {
3576
+ const child = spawn(opener.cmd, opener.args, { stdio: "ignore", shell: false, detached: true });
3577
+ child.on("error", () => resolve(false));
3578
+ child.on("close", (code) => resolve(code === 0));
3579
+ child.unref();
3580
+ });
3581
+ }
3582
+ function formatWorkspaceList(workspaces) {
3583
+ const lines = ["", " Available workspaces:", ""];
3584
+ workspaces.forEach((ws, i) => {
3585
+ const name = ws.name ?? ws.workspace_id;
3586
+ const role = ws.role ? ` (${ws.role})` : "";
3587
+ lines.push(` ${i + 1}. ${name}${role}`);
3588
+ if (ws.name) lines.push(` ${ws.workspace_id}`);
3589
+ });
3590
+ lines.push("");
3591
+ return lines.join("\n");
3592
+ }
3593
+
3146
3594
  // src/index.ts
3147
3595
  var DEFAULT_BASE_URL = "https://api.platform.amigo.ai";
3148
3596
  var AmigoClient = class _AmigoClient {