@pyai/sdk 0.3.1 → 0.5.0

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.
@@ -0,0 +1,148 @@
1
+ /** Browser approval with outbound-only polling, suitable for local and SSH terminals. */
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { spawn } from "node:child_process";
4
+ import { CliError, type CliHttp } from "./cli-http.ts";
5
+ import { normalizeBaseURL, validateApiKey } from "./cli-config.ts";
6
+
7
+ export interface AuthorizationNotice {
8
+ event: "authorization_required";
9
+ verification_uri: string;
10
+ verification_uri_complete: string;
11
+ user_code: string;
12
+ expires_in: number;
13
+ }
14
+
15
+ export interface BrowserCredential {
16
+ api_key: string;
17
+ key_id: string;
18
+ org_id: string;
19
+ project_id: string;
20
+ environment: "live" | "test";
21
+ scopes: string[];
22
+ expires_at: number;
23
+ }
24
+
25
+ export interface BrowserLoginOptions {
26
+ http: Pick<CliHttp, "json">;
27
+ baseURL: string;
28
+ noBrowser?: boolean;
29
+ timeoutMs?: number;
30
+ requestTimeoutMs?: number;
31
+ onAuthorization: (notice: AuthorizationNotice) => void;
32
+ onBrowserUnavailable?: () => void;
33
+ onSecret?: (secret: string) => void;
34
+ // Injectable boundaries keep protocol tests offline and deterministic.
35
+ open?: (url: string) => Promise<boolean>;
36
+ now?: () => number;
37
+ sleep?: (ms: number) => Promise<void>;
38
+ }
39
+
40
+ /** No shell interpolation, URL handlers, or incoming callback listener. */
41
+ export async function openBrowser(url: string): Promise<boolean> {
42
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32.exe" : "xdg-open";
43
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
44
+ return new Promise(resolve => {
45
+ try {
46
+ const child = spawn(command, args, { detached: true, stdio: "ignore", shell: false });
47
+ child.once("error", () => resolve(false));
48
+ child.once("spawn", () => { child.unref(); resolve(true); });
49
+ } catch { resolve(false); }
50
+ });
51
+ }
52
+
53
+ function invalidResponse(): never {
54
+ throw new CliError("invalid_response", "The API returned an invalid browser authorization response", 1);
55
+ }
56
+
57
+ function verificationURLs(data: Record<string, unknown>, baseURL: string): AuthorizationNotice {
58
+ const code = data.user_code;
59
+ if (typeof code !== "string" || !/^[A-Za-z0-9-]{6,24}$/.test(code)) invalidResponse();
60
+ if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0 || data.expires_in > 3600) invalidResponse();
61
+ let plain: URL; let complete: URL;
62
+ try { plain = new URL(String(data.verification_uri)); complete = new URL(String(data.verification_uri_complete)); }
63
+ catch { invalidResponse(); }
64
+ // Never hand a custom protocol or a credential-bearing URL to the OS browser launcher.
65
+ try { normalizeBaseURL(plain!.origin); normalizeBaseURL(complete!.origin); } catch { invalidResponse(); }
66
+ if (plain!.username || plain!.password || plain!.search || plain!.hash || plain!.pathname !== "/cli/login"
67
+ || complete!.username || complete!.password || complete!.hash || complete!.origin !== plain!.origin
68
+ || complete!.pathname !== "/cli/login" || complete!.searchParams.get("user_code") !== code
69
+ || [...complete!.searchParams.keys()].some(key => key !== "user_code")
70
+ || complete!.searchParams.getAll("user_code").length !== 1) invalidResponse();
71
+ if (new URL(baseURL).origin === "https://api.pyai.com" && plain!.origin !== "https://console.pyai.com") invalidResponse();
72
+ return { event: "authorization_required", verification_uri: plain!.href,
73
+ verification_uri_complete: complete!.href, user_code: code, expires_in: data.expires_in };
74
+ }
75
+
76
+ function redact(error: unknown, secrets: string[]): unknown {
77
+ if (!(error instanceof CliError)) return error;
78
+ const safe = (value: unknown): unknown => {
79
+ if (typeof value === "string") return secrets.reduce((v, s) => s ? v.split(s).join("[REDACTED]").split(encodeURIComponent(s)).join("[REDACTED]") : v, value);
80
+ if (Array.isArray(value)) return value.map(safe);
81
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, safe(v)]));
82
+ return value;
83
+ };
84
+ return new CliError(safe(error.code) as string, safe(error.message) as string, error.exitCode, safe(error.details) as Record<string, unknown> | undefined);
85
+ }
86
+
87
+ export async function browserLogin(options: BrowserLoginOptions): Promise<BrowserCredential> {
88
+ const now = options.now ?? Date.now;
89
+ const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
90
+ const duration = options.timeoutMs ?? 600_000;
91
+ if (!Number.isFinite(duration) || duration <= 0 || duration > 3_600_000) throw new CliError("invalid_arguments", "Login timeout must be between 0 and 3600 seconds", 2);
92
+ const started = now();
93
+ const verifier = randomBytes(32).toString("base64url");
94
+ const secrets = [verifier];
95
+ options.onSecret?.(verifier);
96
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
97
+ try {
98
+ let grant;
99
+ try {
100
+ grant = await options.http.json("POST", "/auth/cli/device", { auth: false,
101
+ timeoutMs: Math.min(duration, options.requestTimeoutMs ?? 30_000),
102
+ json: { client_name: "PyAI CLI", code_challenge: challenge, code_challenge_method: "S256" } });
103
+ } catch (error) {
104
+ if (error instanceof CliError && error.details?.status === 404) {
105
+ throw new CliError("browser_login_unavailable", "Browser login is not available on this deployment yet. Use auth login --key-stdin or deploy the CLI login API and console page.", 1);
106
+ }
107
+ throw error;
108
+ }
109
+ if (!grant || typeof grant !== "object" || typeof grant.device_code !== "string"
110
+ || grant.device_code.length < 32 || grant.device_code.length > 512 || /[\s\x00-\x1f\x7f]/.test(grant.device_code)) invalidResponse();
111
+ secrets.push(grant.device_code); options.onSecret?.(grant.device_code);
112
+ const notice = verificationURLs(grant, options.baseURL);
113
+ if (typeof grant.interval !== "number" || !Number.isFinite(grant.interval) || grant.interval < 1 || grant.interval > 60) invalidResponse();
114
+ let interval = grant.interval * 1000;
115
+ const deadline = Math.min(started + duration, now() + notice.expires_in * 1000);
116
+ options.onAuthorization(notice);
117
+ if (!options.noBrowser && !(await (options.open ?? openBrowser)(notice.verification_uri_complete))) options.onBrowserUnavailable?.();
118
+ for (;;) {
119
+ const remaining = deadline - now();
120
+ if (remaining <= interval) {
121
+ if (remaining > 0) await sleep(remaining);
122
+ throw new CliError("login_timeout", "Browser login expired before completion. Run pyai auth login again.", 4);
123
+ }
124
+ await sleep(interval);
125
+ try {
126
+ const result = await options.http.json("POST", "/auth/cli/device/token", { auth: false,
127
+ timeoutMs: Math.min(deadline - now(), options.requestTimeoutMs ?? 30_000),
128
+ json: { device_code: grant.device_code, code_verifier: verifier } });
129
+ if (!result || typeof result.api_key !== "string") invalidResponse();
130
+ secrets.push(result.api_key); options.onSecret?.(result.api_key);
131
+ validateApiKey(result.api_key);
132
+ if (!["key_id", "org_id", "project_id"].every(key => typeof result[key] === "string" && result[key].length > 0)
133
+ || !["test", "live"].includes(result.environment) || !Array.isArray(result.scopes) || !result.scopes.every((s: unknown) => typeof s === "string")
134
+ || typeof result.expires_at !== "number" || !Number.isFinite(result.expires_at) || result.expires_at <= now()) invalidResponse();
135
+ return result as BrowserCredential;
136
+ } catch (error) {
137
+ if (!(error instanceof CliError)) throw error;
138
+ // Only protocol-level pending responses are safe to poll again. A lost
139
+ // success is ambiguous: do not replay a potentially consumed grant.
140
+ if (error.details?.status === 400 && error.code === "authorization_pending") continue;
141
+ if (error.details?.status === 400 && error.code === "slow_down") { interval += 5000; continue; }
142
+ if (error.code === "access_denied") throw new CliError("access_denied", "Browser login was declined. No credentials were saved.", 3);
143
+ if (["expired_token", "invalid_grant"].includes(error.code)) throw new CliError(error.code, "This login request has expired or was already used. Run pyai auth login again.", 3);
144
+ throw error;
145
+ }
146
+ }
147
+ } catch (error) { throw redact(error, secrets); }
148
+ }