From 1aec1001952dd8318c116cb6476fd7c1f8b076b7 Mon Sep 17 00:00:00 2001 From: Guy Ben Aharon Date: Mon, 11 May 2026 20:39:21 +0300 Subject: [PATCH] feat!: add org API key support and rename provisionUser to provisionProject --- src/agents/index.ts | 37 ++++++++++++++++------ src/approvals/index.ts | 58 +++++++++++++++++++++++++--------- src/client.ts | 69 +++++++++++++++++++++++++++++------------ src/container/index.ts | 61 ++++++++++++++++++++++++------------ src/container/types.ts | 16 ++++++---- src/index.ts | 6 ++-- src/provisions/index.ts | 46 ++++++++++++++++++--------- src/provisions/types.ts | 4 +-- src/request-options.ts | 12 +++++++ src/types.ts | 10 +++++- 10 files changed, 229 insertions(+), 90 deletions(-) create mode 100644 src/request-options.ts diff --git a/src/agents/index.ts b/src/agents/index.ts index 7b4621f..d574f01 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -8,16 +8,38 @@ import type { CreateAgentResponse, EnsureAgentResponse, } from "./types.js"; +import type { RequestOptions } from "../request-options.js"; export class AgentsClient { private baseUrl: string; private apiKey: string; private timeout: number; + private defaultProjectId: string | null; - constructor(baseUrl: string, apiKey: string, timeout: number) { + constructor( + baseUrl: string, + apiKey: string, + timeout: number, + defaultProjectId: string | null, + ) { this.baseUrl = baseUrl.replace(/\/+$/, ""); this.apiKey = apiKey; this.timeout = timeout; + this.defaultProjectId = defaultProjectId; + } + + private buildHeaders(options?: RequestOptions): Record { + const headers: Record = { + "Content-Type": "application/json", + }; + if (this.apiKey) { + headers["Authorization"] = `Bearer ${this.apiKey}`; + } + const projectId = options?.projectId ?? this.defaultProjectId; + if (projectId) { + headers["X-Project-Id"] = projectId; + } + return headers; } /** @@ -25,20 +47,14 @@ export class AgentsClient { */ createAgent = async ( input: CreateAgentInput, + options?: RequestOptions, ): Promise => { const url = `${this.baseUrl}/api/agents`; - const headers: Record = { - "Content-Type": "application/json", - }; - if (this.apiKey) { - headers["Authorization"] = `Bearer ${this.apiKey}`; - } - try { const res = await fetch(url, { method: "POST", - headers, + headers: this.buildHeaders(options), body: JSON.stringify(input), signal: AbortSignal.timeout(this.timeout), }); @@ -68,9 +84,10 @@ export class AgentsClient { */ ensureAgent = async ( input: CreateAgentInput, + options?: RequestOptions, ): Promise => { try { - await this.createAgent(input); + await this.createAgent(input, options); return { name: input.name, identifier: input.identifier, created: true }; } catch (error) { if (error instanceof OneCLIRequestError && error.statusCode === 409) { diff --git a/src/approvals/index.ts b/src/approvals/index.ts index 2776328..3e7a523 100644 --- a/src/approvals/index.ts +++ b/src/approvals/index.ts @@ -1,5 +1,6 @@ import { OneCLIRequestError } from "../errors.js"; import type { ApprovalRequest, ManualApprovalCallback } from "./types.js"; +import type { RequestOptions } from "../request-options.js"; /** Internal response shape from the gateway long-poll endpoint. */ interface PollResponse { @@ -11,6 +12,7 @@ export class ApprovalClient { private baseUrl: string; private apiKey: string; private gatewayUrl: string | null; + private defaultProjectId: string | null; private running = false; private abortController: AbortController | null = null; @@ -21,22 +23,41 @@ export class ApprovalClient { */ private inFlight = new Set(); - constructor(baseUrl: string, apiKey: string, gatewayUrl: string | null) { + constructor( + baseUrl: string, + apiKey: string, + gatewayUrl: string | null, + defaultProjectId: string | null, + ) { this.baseUrl = baseUrl.replace(/\/+$/, ""); this.apiKey = apiKey; this.gatewayUrl = gatewayUrl; + this.defaultProjectId = defaultProjectId; + } + + private buildAuthHeaders(projectId?: string | null): Record { + const headers: Record = { + Authorization: `Bearer ${this.apiKey}`, + }; + const resolved = projectId ?? this.defaultProjectId; + if (resolved) { + headers["X-Project-Id"] = resolved; + } + return headers; } /** * Resolve the gateway URL from the web app. * Called once on first poll, then cached. */ - private async resolveGatewayUrl(): Promise { + private async resolveGatewayUrl( + projectId?: string | null, + ): Promise { if (this.gatewayUrl) return this.gatewayUrl; const url = `${this.baseUrl}/api/gateway-url`; const res = await fetch(url, { - headers: { Authorization: `Bearer ${this.apiKey}` }, + headers: this.buildAuthHeaders(projectId), signal: AbortSignal.timeout(5000), }); @@ -62,19 +83,23 @@ export class ApprovalClient { * submission fails), the ID is removed from `inFlight` and the * approval will be retried on the next poll cycle. */ - async start(callback: ManualApprovalCallback): Promise { + async start( + callback: ManualApprovalCallback, + options?: RequestOptions, + ): Promise { this.running = true; - const gatewayUrl = await this.resolveGatewayUrl(); + const projectId = options?.projectId ?? null; + const gatewayUrl = await this.resolveGatewayUrl(projectId); while (this.running) { try { - const poll = await this.poll(gatewayUrl); + const poll = await this.poll(gatewayUrl, projectId); for (const request of poll.requests) { this.inFlight.add(request.id); request.timeoutSeconds = poll.timeoutSeconds; - this.handleRequest(gatewayUrl, request, callback); + this.handleRequest(gatewayUrl, request, callback, projectId); } } catch { if (!this.running) return; @@ -92,11 +117,12 @@ export class ApprovalClient { gatewayUrl: string, request: ApprovalRequest, callback: ManualApprovalCallback, + projectId?: string | null, ): void { (async () => { try { const decision = await callback(request); - await this.submitDecision(gatewayUrl, request.id, decision); + await this.submitDecision(gatewayUrl, request.id, decision, projectId); } finally { this.inFlight.delete(request.id); } @@ -115,7 +141,10 @@ export class ApprovalClient { * Long-poll the gateway for pending approvals. * Server holds up to 30s; we set a 35s client timeout. */ - private async poll(gatewayUrl: string): Promise { + private async poll( + gatewayUrl: string, + projectId?: string | null, + ): Promise { this.abortController = new AbortController(); let url = `${gatewayUrl}/api/approvals/pending`; @@ -124,7 +153,7 @@ export class ApprovalClient { url += `?exclude=${encodeURIComponent(exclude)}`; } const res = await fetch(url, { - headers: { Authorization: `Bearer ${this.apiKey}` }, + headers: this.buildAuthHeaders(projectId), signal: AbortSignal.any([ this.abortController.signal, AbortSignal.timeout(35_000), @@ -146,15 +175,16 @@ export class ApprovalClient { gatewayUrl: string, id: string, decision: string, + projectId?: string | null, ): Promise { const url = `${gatewayUrl}/api/approvals/${encodeURIComponent(id)}/decision`; + const headers = this.buildAuthHeaders(projectId); + headers["Content-Type"] = "application/json"; + const res = await fetch(url, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - }, + headers, body: JSON.stringify({ decision }), signal: AbortSignal.timeout(5000), }); diff --git a/src/client.ts b/src/client.ts index 98eb7de..d05d4da 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3,9 +3,11 @@ import { AgentsClient } from "./agents/index.js"; import { ApprovalClient } from "./approvals/index.js"; import { ProvisionClient } from "./provisions/index.js"; import type { OneCLIOptions } from "./types.js"; +import type { RequestOptions } from "./request-options.js"; import type { ApplyContainerConfigOptions, ContainerConfig, + GetContainerConfigOptions, } from "./container/types.js"; import type { CreateAgentInput, @@ -17,8 +19,8 @@ import type { ManualApprovalHandle, } from "./approvals/types.js"; import type { - ProvisionUserInput, - ProvisionUserResponse, + ProvisionProjectInput, + ProvisionProjectResponse, } from "./provisions/types.js"; const DEFAULT_URL = "https://app.onecli.sh"; @@ -36,25 +38,44 @@ export class OneCLI { const timeout = options.timeout ?? DEFAULT_TIMEOUT; const gatewayUrl = options.gatewayUrl ?? process.env.ONECLI_GATEWAY_URL ?? null; + const projectId = + options.projectId ?? process.env.ONECLI_PROJECT_ID ?? null; - this.containerClient = new ContainerClient(url, apiKey, timeout); - this.agentsClient = new AgentsClient(url, apiKey, timeout); - this.approvalClient = new ApprovalClient(url, apiKey, gatewayUrl); - this.provisionClient = new ProvisionClient(url, apiKey, timeout); + this.containerClient = new ContainerClient( + url, + apiKey, + timeout, + projectId, + ); + this.agentsClient = new AgentsClient(url, apiKey, timeout, projectId); + this.approvalClient = new ApprovalClient( + url, + apiKey, + gatewayUrl, + projectId, + ); + this.provisionClient = new ProvisionClient( + url, + apiKey, + timeout, + projectId, + ); } /** * Fetch the gateway skill markdown from OneCLI. */ - getGatewaySkill = (): Promise => { - return this.containerClient.getGatewaySkill(); + getGatewaySkill = (options?: RequestOptions): Promise => { + return this.containerClient.getGatewaySkill(options); }; /** * Fetch the raw container configuration from OneCLI. */ - getContainerConfig = (agent?: string): Promise => { - return this.containerClient.getContainerConfig(agent); + getContainerConfig = ( + options?: GetContainerConfigOptions, + ): Promise => { + return this.containerClient.getContainerConfig(options); }; /** @@ -71,26 +92,33 @@ export class OneCLI { /** * Create a new agent. */ - createAgent = (input: CreateAgentInput): Promise => { - return this.agentsClient.createAgent(input); + createAgent = ( + input: CreateAgentInput, + options?: RequestOptions, + ): Promise => { + return this.agentsClient.createAgent(input, options); }; /** * Ensure an agent exists. Creates it if missing, returns normally if it already exists. */ - ensureAgent = (input: CreateAgentInput): Promise => { - return this.agentsClient.ensureAgent(input); + ensureAgent = ( + input: CreateAgentInput, + options?: RequestOptions, + ): Promise => { + return this.agentsClient.ensureAgent(input, options); }; /** - * Provision a new user in your organization. + * Provision a new project in your organization. * Pre-creates a user account, project, and API key. * Returns a claim URL and API key. Requires admin/owner role. */ - provisionUser = ( - input?: ProvisionUserInput, - ): Promise => { - return this.provisionClient.provisionUser(input); + provisionProject = ( + input?: ProvisionProjectInput, + options?: RequestOptions, + ): Promise => { + return this.provisionClient.provisionProject(input, options); }; /** @@ -101,8 +129,9 @@ export class OneCLI { */ configureManualApproval = ( callback: ManualApprovalCallback, + options?: RequestOptions, ): ManualApprovalHandle => { - this.approvalClient.start(callback).catch(() => { + this.approvalClient.start(callback, options).catch(() => { // Errors handled internally with backoff }); return { stop: () => this.approvalClient.stop() }; diff --git a/src/container/index.ts b/src/container/index.ts index 34d611d..5d5a164 100644 --- a/src/container/index.ts +++ b/src/container/index.ts @@ -1,31 +1,50 @@ import { OneCLIError, OneCLIRequestError, toOneCLIError } from "../errors.js"; import { writeCaCertificate, buildCombinedCaBundle } from "./ca.js"; -import type { ApplyContainerConfigOptions, ContainerConfig } from "./types.js"; +import type { + ApplyContainerConfigOptions, + ContainerConfig, + GetContainerConfigOptions, +} from "./types.js"; +import type { RequestOptions } from "../request-options.js"; export class ContainerClient { private baseUrl: string; private apiKey: string; private timeout: number; - - constructor(baseUrl: string, apiKey: string, timeout: number) { + private defaultProjectId: string | null; + + constructor( + baseUrl: string, + apiKey: string, + timeout: number, + defaultProjectId: string | null, + ) { this.baseUrl = baseUrl.replace(/\/+$/, ""); this.apiKey = apiKey; this.timeout = timeout; + this.defaultProjectId = defaultProjectId; + } + + private buildHeaders(options?: RequestOptions): Record { + const headers: Record = {}; + if (this.apiKey) { + headers["Authorization"] = `Bearer ${this.apiKey}`; + } + const projectId = options?.projectId ?? this.defaultProjectId; + if (projectId) { + headers["X-Project-Id"] = projectId; + } + return headers; } /** * Fetch the gateway skill markdown from OneCLI. */ - getGatewaySkill = async (): Promise => { + getGatewaySkill = async (options?: RequestOptions): Promise => { const url = `${this.baseUrl}/api/skill/gateway`; try { - const headers: Record = {}; - if (this.apiKey) { - headers["Authorization"] = `Bearer ${this.apiKey}`; - } - const res = await fetch(url, { - headers, + headers: this.buildHeaders(options), signal: AbortSignal.timeout(this.timeout), }); @@ -51,19 +70,17 @@ export class ContainerClient { /** * Fetch the raw container configuration from OneCLI. */ - getContainerConfig = async (agent?: string): Promise => { + getContainerConfig = async ( + options?: GetContainerConfigOptions, + ): Promise => { + const { agent, ...requestOptions } = options ?? {}; const url = agent ? `${this.baseUrl}/api/container-config?agent=${encodeURIComponent(agent)}` : `${this.baseUrl}/api/container-config`; try { - const headers: Record = {}; - if (this.apiKey) { - headers["Authorization"] = `Bearer ${this.apiKey}`; - } - const res = await fetch(url, { - headers, + headers: this.buildHeaders(requestOptions), signal: AbortSignal.timeout(this.timeout), }); @@ -97,12 +114,16 @@ export class ContainerClient { args: string[], options?: ApplyContainerConfigOptions, ): Promise => { - const { combineCaBundle = true, addHostMapping = true, agent } = - options ?? {}; + const { + combineCaBundle = true, + addHostMapping = true, + agent, + projectId, + } = options ?? {}; let config: ContainerConfig; try { - config = await this.getContainerConfig(agent); + config = await this.getContainerConfig({ agent, projectId }); } catch { return false; } diff --git a/src/container/types.ts b/src/container/types.ts index d020ead..ea41ebc 100644 --- a/src/container/types.ts +++ b/src/container/types.ts @@ -1,10 +1,19 @@ +import type { RequestOptions } from "../request-options.js"; + export interface ContainerConfig { env: Record; caCertificate: string; caCertificateContainerPath: string; } -export interface ApplyContainerConfigOptions { +export interface GetContainerConfigOptions extends RequestOptions { + /** + * Agent identifier to fetch config for. Uses the default agent if omitted. + */ + agent?: string; +} + +export interface ApplyContainerConfigOptions extends GetContainerConfigOptions { /** * Build a combined CA bundle (system CAs + OneCLI CA) for full system trust. * When enabled, tools like curl, Python, and Go will also trust OneCLI. @@ -18,9 +27,4 @@ export interface ApplyContainerConfigOptions { * @default true */ addHostMapping?: boolean; - - /** - * Agent identifier to fetch config for. Uses the default agent if omitted. - */ - agent?: string; } diff --git a/src/index.ts b/src/index.ts index 0a454c1..b761bef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,10 @@ export { ProvisionClient } from "./provisions/index.js"; export { OneCLIError, OneCLIRequestError } from "./errors.js"; export type { OneCLIOptions } from "./types.js"; +export type { RequestOptions } from "./request-options.js"; export type { ContainerConfig, + GetContainerConfigOptions, ApplyContainerConfigOptions, } from "./container/types.js"; export type { @@ -21,6 +23,6 @@ export type { ManualApprovalHandle, } from "./approvals/types.js"; export type { - ProvisionUserInput, - ProvisionUserResponse, + ProvisionProjectInput, + ProvisionProjectResponse, } from "./provisions/types.js"; diff --git a/src/provisions/index.ts b/src/provisions/index.ts index faee4c3..e48e964 100644 --- a/src/provisions/index.ts +++ b/src/provisions/index.ts @@ -3,40 +3,56 @@ import { OneCLIRequestError, toOneCLIError, } from "../errors.js"; -import type { ProvisionUserInput, ProvisionUserResponse } from "./types.js"; +import type { ProvisionProjectInput, ProvisionProjectResponse } from "./types.js"; +import type { RequestOptions } from "../request-options.js"; export class ProvisionClient { private baseUrl: string; private apiKey: string; private timeout: number; + private defaultProjectId: string | null; - constructor(baseUrl: string, apiKey: string, timeout: number) { + constructor( + baseUrl: string, + apiKey: string, + timeout: number, + defaultProjectId: string | null, + ) { this.baseUrl = baseUrl.replace(/\/+$/, ""); this.apiKey = apiKey; this.timeout = timeout; + this.defaultProjectId = defaultProjectId; } - /** - * Provision a new user in your organization. - * Pre-creates a user account, project, and API key. - * Returns a claim URL and API key. Requires admin/owner role. - */ - provisionUser = async ( - input?: ProvisionUserInput, - ): Promise => { - const url = `${this.baseUrl}/api/team/provisions`; - + private buildHeaders(options?: RequestOptions): Record { const headers: Record = { "Content-Type": "application/json", }; if (this.apiKey) { headers["Authorization"] = `Bearer ${this.apiKey}`; } + const projectId = options?.projectId ?? this.defaultProjectId; + if (projectId) { + headers["X-Project-Id"] = projectId; + } + return headers; + } + + /** + * Provision a new project in your organization. + * Pre-creates a user account, project, and API key. + * Returns a claim URL and API key. Requires admin/owner role. + */ + provisionProject = async ( + input?: ProvisionProjectInput, + options?: RequestOptions, + ): Promise => { + const url = `${this.baseUrl}/api/team/provisions`; try { const res = await fetch(url, { method: "POST", - headers, + headers: this.buildHeaders(options), body: JSON.stringify(input ?? {}), signal: AbortSignal.timeout(this.timeout), }); @@ -44,7 +60,7 @@ export class ProvisionClient { if (!res.ok) { if (res.status === 404) { throw new OneCLIError( - "User provisioning requires OneCLI Cloud. See https://onecli.sh for details.", + "Project provisioning requires OneCLI Cloud. See https://onecli.sh for details.", ); } throw new OneCLIRequestError( @@ -53,7 +69,7 @@ export class ProvisionClient { ); } - return (await res.json()) as ProvisionUserResponse; + return (await res.json()) as ProvisionProjectResponse; } catch (error) { if ( error instanceof OneCLIError || diff --git a/src/provisions/types.ts b/src/provisions/types.ts index 3a6af17..6d27f20 100644 --- a/src/provisions/types.ts +++ b/src/provisions/types.ts @@ -1,4 +1,4 @@ -export interface ProvisionUserInput { +export interface ProvisionProjectInput { /** Role for the provisioned user. Defaults to "member". */ role?: "admin" | "member"; @@ -6,7 +6,7 @@ export interface ProvisionUserInput { skipOnboarding?: boolean; } -export interface ProvisionUserResponse { +export interface ProvisionProjectResponse { /** Provision record ID. */ id: string; diff --git a/src/request-options.ts b/src/request-options.ts new file mode 100644 index 0000000..0e49d30 --- /dev/null +++ b/src/request-options.ts @@ -0,0 +1,12 @@ +/** + * Per-operation options that can override constructor defaults. + * Used with organization-level API keys (`oc_org_...`) to specify + * which project to target. + */ +export interface RequestOptions { + /** + * Project ID to target. Required for org-level API keys (`oc_org_...`). + * Overrides the default `projectId` from the constructor. + */ + projectId?: string; +} diff --git a/src/types.ts b/src/types.ts index 202beb2..2ca0eab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ export interface OneCLIOptions { /** - * User API key from the OneCLI dashboard (starts with `oc_`). + * API key from the OneCLI dashboard. + * Supports both project keys (`oc_...`) and org keys (`oc_org_...`). * Falls back to `ONECLI_API_KEY` env var if not provided. */ apiKey?: string; @@ -23,4 +24,11 @@ export interface OneCLIOptions { * from the web app via `GET /api/gateway-url`. */ gatewayUrl?: string; + + /** + * Default project ID for org-level API keys (`oc_org_...`). + * Falls back to `ONECLI_PROJECT_ID` env var. + * Can be overridden per-operation via `RequestOptions`. + */ + projectId?: string; }