@seclai/sdk 1.1.0 → 1.1.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.js CHANGED
@@ -48,9 +48,283 @@ var SeclaiStreamingError = class extends SeclaiError {
48
48
  }
49
49
  };
50
50
 
51
+ // src/auth.ts
52
+ var DEFAULT_CONFIG_DIR = ".seclai";
53
+ var SSO_CACHE_DIR = "sso/cache";
54
+ var CONFIG_FILE = "config";
55
+ var EXPIRY_BUFFER_MS = 3e4;
56
+ var DEFAULT_API_KEY_HEADER = "x-api-key";
57
+ function getEnv(name) {
58
+ const p = globalThis?.process;
59
+ return p?.env?.[name];
60
+ }
61
+ function getHomeDir() {
62
+ const p = globalThis?.process;
63
+ return p?.env?.HOME ?? p?.env?.USERPROFILE;
64
+ }
65
+ async function sha1Hex(input) {
66
+ try {
67
+ const { createHash } = await import("crypto");
68
+ return createHash("sha1").update(input).digest("hex");
69
+ } catch {
70
+ const encoded = new TextEncoder().encode(input);
71
+ const buffer = await crypto.subtle.digest("SHA-1", encoded);
72
+ return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
73
+ }
74
+ }
75
+ async function cacheFileName(domain, clientId) {
76
+ return sha1Hex(`${domain}|${clientId}`);
77
+ }
78
+ function parseIni(content) {
79
+ const sections = {};
80
+ let currentSection = null;
81
+ for (const rawLine of content.split(/\r?\n/)) {
82
+ const line = rawLine.trim();
83
+ if (!line || line.startsWith("#") || line.startsWith(";")) continue;
84
+ const sectionMatch = line.match(/^\[(.+)\]$/);
85
+ if (sectionMatch) {
86
+ const raw = sectionMatch[1].trim();
87
+ currentSection = raw.startsWith("profile ") ? raw.slice("profile ".length).trim() : raw;
88
+ sections[currentSection] ??= {};
89
+ continue;
90
+ }
91
+ if (currentSection !== null) {
92
+ const eqIdx = line.indexOf("=");
93
+ if (eqIdx > 0) {
94
+ const key = line.slice(0, eqIdx).trim();
95
+ const value = line.slice(eqIdx + 1).trim();
96
+ sections[currentSection][key] = value;
97
+ }
98
+ }
99
+ }
100
+ return sections;
101
+ }
102
+ var _fs = null;
103
+ var _path = null;
104
+ async function getFs() {
105
+ if (!_fs) {
106
+ _fs = await import("fs");
107
+ }
108
+ return _fs;
109
+ }
110
+ async function getPath() {
111
+ if (!_path) {
112
+ _path = await import("path");
113
+ }
114
+ return _path;
115
+ }
116
+ async function resolveConfigDir(override) {
117
+ if (override) return override;
118
+ const envDir = getEnv("SECLAI_CONFIG_DIR");
119
+ if (envDir) return envDir;
120
+ const home = getHomeDir();
121
+ if (!home) {
122
+ throw new Error("Cannot determine home directory. Set SECLAI_CONFIG_DIR.");
123
+ }
124
+ const pathMod = await getPath();
125
+ return pathMod.join(home, DEFAULT_CONFIG_DIR);
126
+ }
127
+ async function loadSsoProfile(configDir, profileName) {
128
+ const fs = await getFs();
129
+ const pathMod = await getPath();
130
+ const configPath = pathMod.join(configDir, CONFIG_FILE);
131
+ if (!fs.existsSync(configPath)) return null;
132
+ const content = fs.readFileSync(configPath, "utf-8");
133
+ const sections = parseIni(content);
134
+ const defaultSection = sections["default"] ?? {};
135
+ const profileSection = profileName === "default" ? defaultSection : sections[profileName];
136
+ if (!profileSection) return null;
137
+ const merged = profileName === "default" ? profileSection : { ...defaultSection, ...profileSection };
138
+ const ssoAccountId = merged["sso_account_id"];
139
+ const ssoRegion = merged["sso_region"];
140
+ const ssoClientId = merged["sso_client_id"];
141
+ const ssoDomain = merged["sso_domain"];
142
+ if (!ssoAccountId || !ssoRegion || !ssoClientId || !ssoDomain) return null;
143
+ return { ssoAccountId, ssoRegion, ssoClientId, ssoDomain };
144
+ }
145
+ async function resolveCachePath(configDir, profile) {
146
+ const pathMod = await getPath();
147
+ const hash = await cacheFileName(profile.ssoDomain, profile.ssoClientId);
148
+ return pathMod.join(configDir, SSO_CACHE_DIR, `${hash}.json`);
149
+ }
150
+ async function readSsoCache(configDir, profile) {
151
+ const fs = await getFs();
152
+ const cachePath = await resolveCachePath(configDir, profile);
153
+ if (!fs.existsSync(cachePath)) return null;
154
+ try {
155
+ const raw = fs.readFileSync(cachePath, "utf-8");
156
+ return JSON.parse(raw);
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+ async function writeSsoCache(configDir, profile, entry) {
162
+ const fs = await getFs();
163
+ const pathMod = await getPath();
164
+ const cacheDir = pathMod.join(configDir, SSO_CACHE_DIR);
165
+ fs.mkdirSync(cacheDir, { recursive: true, mode: 448 });
166
+ const cachePath = await resolveCachePath(configDir, profile);
167
+ const tmpPath = `${cachePath}.tmp`;
168
+ fs.writeFileSync(tmpPath, JSON.stringify(entry, null, 2), { mode: 384 });
169
+ if (fs.existsSync(cachePath)) {
170
+ try {
171
+ fs.unlinkSync(cachePath);
172
+ } catch {
173
+ }
174
+ }
175
+ fs.renameSync(tmpPath, cachePath);
176
+ }
177
+ function isTokenValid(entry) {
178
+ const expiresAt = new Date(entry.expiresAt).getTime();
179
+ return Date.now() + EXPIRY_BUFFER_MS < expiresAt;
180
+ }
181
+ async function refreshToken(profile, refreshTokenValue, fetcher) {
182
+ const tokenUrl = `https://${profile.ssoDomain}/oauth2/token`;
183
+ const body = new URLSearchParams({
184
+ grant_type: "refresh_token",
185
+ client_id: profile.ssoClientId,
186
+ refresh_token: refreshTokenValue
187
+ });
188
+ const response = await fetcher(tokenUrl, {
189
+ method: "POST",
190
+ headers: { "content-type": "application/x-www-form-urlencoded" },
191
+ body: body.toString()
192
+ });
193
+ if (!response.ok) {
194
+ const text = await response.text().catch(() => "");
195
+ throw new Error(`Token refresh failed (HTTP ${response.status}): ${text}`);
196
+ }
197
+ const data = await response.json();
198
+ const expiresAt = new Date(Date.now() + data.expires_in * 1e3).toISOString();
199
+ return {
200
+ accessToken: data.access_token,
201
+ refreshToken: data.refresh_token ?? refreshTokenValue,
202
+ idToken: data.id_token ?? void 0,
203
+ expiresAt,
204
+ clientId: profile.ssoClientId,
205
+ region: profile.ssoRegion,
206
+ cognitoDomain: profile.ssoDomain
207
+ };
208
+ }
209
+ async function resolveCredentialChain(opts) {
210
+ const apiKeyHeader = opts.apiKeyHeader ?? DEFAULT_API_KEY_HEADER;
211
+ if (opts.apiKey) {
212
+ return {
213
+ mode: "apiKey",
214
+ apiKey: opts.apiKey,
215
+ apiKeyHeader,
216
+ accountId: opts.accountId,
217
+ autoRefresh: false
218
+ };
219
+ }
220
+ if (opts.accessToken) {
221
+ return {
222
+ mode: "bearerStatic",
223
+ accessToken: opts.accessToken,
224
+ apiKeyHeader,
225
+ accountId: opts.accountId,
226
+ autoRefresh: false
227
+ };
228
+ }
229
+ if (opts.accessTokenProvider) {
230
+ return {
231
+ mode: "bearerProvider",
232
+ accessTokenProvider: opts.accessTokenProvider,
233
+ apiKeyHeader,
234
+ accountId: opts.accountId,
235
+ autoRefresh: false
236
+ };
237
+ }
238
+ const envApiKey = getEnv("SECLAI_API_KEY");
239
+ if (envApiKey) {
240
+ return {
241
+ mode: "apiKey",
242
+ apiKey: envApiKey,
243
+ apiKeyHeader,
244
+ accountId: opts.accountId,
245
+ autoRefresh: false
246
+ };
247
+ }
248
+ try {
249
+ const configDir = await resolveConfigDir(opts.configDir);
250
+ const profileName = opts.profile ?? getEnv("SECLAI_PROFILE") ?? "default";
251
+ const ssoProfile = await loadSsoProfile(configDir, profileName);
252
+ if (ssoProfile) {
253
+ return {
254
+ mode: "sso",
255
+ apiKeyHeader,
256
+ accountId: opts.accountId ?? ssoProfile.ssoAccountId,
257
+ ssoProfile,
258
+ configDir,
259
+ autoRefresh: opts.autoRefresh !== false,
260
+ fetcher: opts.fetch
261
+ };
262
+ }
263
+ } catch {
264
+ }
265
+ throw new Error(
266
+ "Missing credentials. Provide apiKey, accessToken, set SECLAI_API_KEY, or run `seclai auth login`."
267
+ );
268
+ }
269
+ async function resolveAuthHeaders(state) {
270
+ const headers = {};
271
+ switch (state.mode) {
272
+ case "apiKey":
273
+ headers[state.apiKeyHeader] = state.apiKey;
274
+ break;
275
+ case "bearerStatic":
276
+ headers["authorization"] = `Bearer ${state.accessToken}`;
277
+ break;
278
+ case "bearerProvider": {
279
+ const token = await Promise.resolve(state.accessTokenProvider());
280
+ headers["authorization"] = `Bearer ${token}`;
281
+ break;
282
+ }
283
+ case "sso": {
284
+ const token = await resolveSsoToken(state);
285
+ headers["authorization"] = `Bearer ${token}`;
286
+ break;
287
+ }
288
+ }
289
+ if (state.accountId) {
290
+ headers["x-account-id"] = state.accountId;
291
+ }
292
+ return headers;
293
+ }
294
+ async function resolveSsoToken(state) {
295
+ const profile = state.ssoProfile;
296
+ const configDir = state.configDir;
297
+ const cached = await readSsoCache(configDir, profile);
298
+ if (cached && isTokenValid(cached)) {
299
+ return cached.accessToken;
300
+ }
301
+ if (cached?.refreshToken && state.autoRefresh) {
302
+ if (state._refreshPromise) {
303
+ return state._refreshPromise;
304
+ }
305
+ const fetcher = state.fetcher ?? globalThis.fetch;
306
+ if (!fetcher) {
307
+ throw new Error("No fetch implementation available for token refresh.");
308
+ }
309
+ state._refreshPromise = (async () => {
310
+ try {
311
+ const refreshed = await refreshToken(profile, cached.refreshToken, fetcher);
312
+ await writeSsoCache(configDir, profile, refreshed);
313
+ return refreshed.accessToken;
314
+ } finally {
315
+ state._refreshPromise = void 0;
316
+ }
317
+ })();
318
+ return state._refreshPromise;
319
+ }
320
+ throw new Error(
321
+ `SSO token expired. Run \`seclai auth login\` to re-authenticate.`
322
+ );
323
+ }
324
+
51
325
  // src/client.ts
52
326
  var SECLAI_API_URL = "https://api.seclai.com";
53
- function getEnv(name) {
327
+ function getEnv2(name) {
54
328
  const p = globalThis?.process;
55
329
  return p?.env?.[name];
56
330
  }
@@ -177,23 +451,29 @@ function inferMimeType(fileName) {
177
451
  return ext ? MIME_TYPES[ext] : void 0;
178
452
  }
179
453
  var Seclai = class {
180
- apiKey;
181
454
  baseUrl;
182
- apiKeyHeader;
183
455
  defaultHeaders;
184
456
  fetcher;
457
+ _authState = null;
458
+ _authInitPromise = null;
459
+ _authInitError = null;
185
460
  /**
186
461
  * Create a new Seclai client.
187
462
  *
463
+ * Credentials are resolved via a chain (first match wins):
464
+ * 1. Explicit `apiKey` option
465
+ * 2. Explicit `accessToken` option (static string or provider function)
466
+ * 3. `SECLAI_API_KEY` environment variable
467
+ * 4. SSO profile from `~/.seclai/config` + cached tokens in `~/.seclai/sso/cache/`
468
+ *
188
469
  * @param opts - Client configuration.
189
- * @throws {@link SeclaiConfigurationError} If no API key is provided (and `SECLAI_API_KEY` is not set).
190
470
  * @throws {@link SeclaiConfigurationError} If no `fetch` implementation is available.
471
+ * @throws {@link SeclaiConfigurationError} If both `apiKey` and `accessToken` are provided.
191
472
  */
192
473
  constructor(opts = {}) {
193
- const apiKey = opts.apiKey ?? getEnv("SECLAI_API_KEY");
194
- if (!apiKey) {
474
+ if (opts.apiKey && opts.accessToken) {
195
475
  throw new SeclaiConfigurationError(
196
- "Missing API key. Provide apiKey or set SECLAI_API_KEY."
476
+ "Provide either apiKey or accessToken, not both."
197
477
  );
198
478
  }
199
479
  const fetcher = opts.fetch ?? globalThis.fetch;
@@ -202,11 +482,49 @@ var Seclai = class {
202
482
  "No fetch implementation available. Provide opts.fetch or run in an environment with global fetch."
203
483
  );
204
484
  }
205
- this.apiKey = apiKey;
206
- this.baseUrl = opts.baseUrl ?? getEnv("SECLAI_API_URL") ?? SECLAI_API_URL;
207
- this.apiKeyHeader = opts.apiKeyHeader ?? "x-api-key";
485
+ this.baseUrl = opts.baseUrl ?? getEnv2("SECLAI_API_URL") ?? SECLAI_API_URL;
208
486
  this.defaultHeaders = { ...opts.defaultHeaders ?? {} };
209
487
  this.fetcher = fetcher;
488
+ const accessTokenProvider = typeof opts.accessToken === "function" ? opts.accessToken : void 0;
489
+ const accessTokenStatic = typeof opts.accessToken === "string" ? opts.accessToken : void 0;
490
+ this._authInitPromise = resolveCredentialChain({
491
+ apiKey: opts.apiKey,
492
+ accessToken: accessTokenStatic,
493
+ accessTokenProvider,
494
+ profile: opts.profile,
495
+ configDir: opts.configDir,
496
+ autoRefresh: opts.autoRefresh,
497
+ accountId: opts.accountId,
498
+ apiKeyHeader: opts.apiKeyHeader,
499
+ fetch: fetcher
500
+ }).then((state) => {
501
+ this._authState = state;
502
+ }).catch((err) => {
503
+ this._authInitError = new SeclaiConfigurationError(
504
+ err instanceof Error ? err.message : String(err)
505
+ );
506
+ });
507
+ }
508
+ /** Ensure the credential chain has been resolved. */
509
+ async ensureAuth() {
510
+ if (this._authInitPromise) {
511
+ await this._authInitPromise;
512
+ this._authInitPromise = null;
513
+ }
514
+ if (this._authInitError) {
515
+ throw this._authInitError;
516
+ }
517
+ if (!this._authState) {
518
+ throw new SeclaiConfigurationError(
519
+ "Missing credentials. Provide apiKey, accessToken, set SECLAI_API_KEY, or run `seclai auth login`."
520
+ );
521
+ }
522
+ return this._authState;
523
+ }
524
+ /** Resolve auth headers for the current request. */
525
+ async authHeaders() {
526
+ const state = await this.ensureAuth();
527
+ return resolveAuthHeaders(state);
210
528
  }
211
529
  // ═══════════════════════════════════════════════════════════════════════════
212
530
  // Low-level request
@@ -225,10 +543,11 @@ var Seclai = class {
225
543
  */
226
544
  async request(method, path, opts) {
227
545
  const url = buildURL(this.baseUrl, path, opts?.query);
546
+ const authHeaders = await this.authHeaders();
228
547
  const headers = {
229
548
  ...this.defaultHeaders,
230
549
  ...opts?.headers ?? {},
231
- [this.apiKeyHeader]: this.apiKey
550
+ ...authHeaders
232
551
  };
233
552
  let body;
234
553
  if (opts?.json !== void 0) {
@@ -280,13 +599,16 @@ var Seclai = class {
280
599
  * @param path - Request path relative to `baseUrl`.
281
600
  * @param opts - Query params, JSON body, per-request headers, and optional AbortSignal.
282
601
  * @returns The raw `Response` object.
602
+ * @throws {SeclaiAPIValidationError} On HTTP 422 responses.
603
+ * @throws {SeclaiAPIStatusError} On other non-2xx responses.
283
604
  */
284
605
  async requestRaw(method, path, opts) {
285
606
  const url = buildURL(this.baseUrl, path, opts?.query);
607
+ const authHeaders = await this.authHeaders();
286
608
  const headers = {
287
609
  ...this.defaultHeaders,
288
610
  ...opts?.headers ?? {},
289
- [this.apiKeyHeader]: this.apiKey
611
+ ...authHeaders
290
612
  };
291
613
  let body;
292
614
  if (opts?.json !== void 0) {
@@ -323,9 +645,10 @@ var Seclai = class {
323
645
  /** Shared multipart upload helper. */
324
646
  async uploadFile(path, opts) {
325
647
  const url = buildURL(this.baseUrl, path);
648
+ const authHeaders = await this.authHeaders();
326
649
  const headers = {
327
650
  ...this.defaultHeaders,
328
- [this.apiKeyHeader]: this.apiKey
651
+ ...authHeaders
329
652
  };
330
653
  delete headers["content-type"];
331
654
  delete headers["Content-Type"];
@@ -513,9 +836,10 @@ var Seclai = class {
513
836
  */
514
837
  async runStreamingAgentAndWait(agentId, body, opts) {
515
838
  const url = buildURL(this.baseUrl, `/agents/${agentId}/runs/stream`);
839
+ const authHdrs = await this.authHeaders();
516
840
  const headers = {
517
841
  ...this.defaultHeaders,
518
- [this.apiKeyHeader]: this.apiKey,
842
+ ...authHdrs,
519
843
  accept: "text/event-stream",
520
844
  "content-type": "application/json"
521
845
  };
@@ -622,9 +946,10 @@ var Seclai = class {
622
946
  */
623
947
  async *runStreamingAgent(agentId, body, opts) {
624
948
  const url = buildURL(this.baseUrl, `/agents/${agentId}/runs/stream`);
949
+ const authHdrs = await this.authHeaders();
625
950
  const headers = {
626
951
  ...this.defaultHeaders,
627
- [this.apiKeyHeader]: this.apiKey,
952
+ ...authHdrs,
628
953
  accept: "text/event-stream",
629
954
  "content-type": "application/json"
630
955
  };