Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,17 @@ await onecli.org.createRule({
const rules = await onecli.org.listRules();
await onecli.org.updateRule(rules[0].id, { enabled: false });
await onecli.org.deleteRule(rules[0].id);

// Watch manual-approval requests across every project in the org. Each request
// carries its own `projectId`, and the decision is routed back to that project.
const handle = onecli.org.configureManualApproval(
async (request) => {
console.log(`[${request.projectId}] ${request.method} ${request.url}`);
return "approve"; // or "deny"
},
{ onError: (err) => console.error("approval poll failed", err) },
);
// handle.stop() when shutting down
```

Rule listings mix two kinds of rules. Custom rules (like the one created
Expand All @@ -432,6 +443,7 @@ are identified by `metadata.provider` + `metadata.toolId` instead.
| `createRule(input)` | `POST /v1/org/rules` | `OrgRule` |
| `updateRule(id, input)` | `PATCH /v1/org/rules/{id}` | `{ success: boolean }` |
| `deleteRule(id)` | `DELETE /v1/org/rules/{id}` | `void` |
| `configureManualApproval(cb, options?)` | `GET /v1/org/approvals/pending` (long-poll) | `ManualApprovalHandle` |

---

Expand All @@ -452,6 +464,9 @@ import type {
ApprovalRequest,
ManualApprovalCallback,
ManualApprovalHandle,
OrgApprovalRequest,
OrgManualApprovalCallback,
OrgManualApprovalOptions,
ProvisionProjectInput,
ProvisionProjectResponse,
ConnectOrgAppInput,
Expand Down
206 changes: 206 additions & 0 deletions src/approvals/org.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { OneCLIRequestError } from "../errors.js";
import type {
OrgApprovalRequest,
OrgManualApprovalCallback,
OrgManualApprovalOptions,
} from "./types.js";

/** Internal response shape from the org-scoped long-poll endpoint. */
interface OrgPollResponse {
requests: OrgApprovalRequest[];
timeoutSeconds: number;
}

/**
* Long-polls the gateway for pending approvals across **every** project in the
* organization (`GET /v1/org/approvals/pending`) — a cross-project sibling of
* `ApprovalClient`. Authenticates with an organization API key (`oc_org_...`)
* and sends **no** `X-Project-Id` on the poll; each returned request carries its
* own `projectId`, which is echoed back as `X-Project-Id` on the decision so the
* gateway routes it to the right project (reusing the existing decision route).
*/
export class OrgApprovalClient {
private baseUrl: string;
private apiKey: string;
private gatewayUrl: string | null;
private running = false;
private abortController: AbortController | null = null;

/**
* Approval IDs currently being processed by a callback. Prevents duplicate
* callback invocations when the poll returns a request again before its
* decision is submitted.
*/
private inFlight = new Set<string>();

constructor(baseUrl: string, apiKey: string, gatewayUrl: string | null) {
this.baseUrl = baseUrl.replace(/\/+$/, "");
this.apiKey = apiKey;
this.gatewayUrl = gatewayUrl;
}

/**
* Auth headers for org requests. The poll sends only the bearer token — the
* organization is derived from the key. A decision additionally carries the
* request's `projectId` as `X-Project-Id` so it lands in the right project.
*/
private buildAuthHeaders(projectId?: string): Record<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${this.apiKey}`,
};
if (projectId) {
headers["X-Project-Id"] = projectId;
}
return headers;
}

/** Resolve the gateway URL from the web app. Called once, then cached. */
private async resolveGatewayUrl(): Promise<string> {
if (this.gatewayUrl) return this.gatewayUrl;

const url = `${this.baseUrl}/v1/gateway-url`;
const res = await fetch(url, {
headers: this.buildAuthHeaders(),
signal: AbortSignal.timeout(5000),
});

if (!res.ok) {
throw new OneCLIRequestError("Failed to resolve gateway URL", {
url,
statusCode: res.status,
});
}

const data = (await res.json()) as { url: string };
this.gatewayUrl = data.url.replace(/\/+$/, "");
return this.gatewayUrl;
}

/**
* Start the long-polling loop. Runs until stop() is called. Dispatches
* callbacks concurrently, one per pending approval. On a failed poll cycle the
* error is passed to `options.onError` (if given) and the loop backs off and
* retries — unlike the project client, it does not swallow errors silently.
*/
async start(
callback: OrgManualApprovalCallback,
options?: OrgManualApprovalOptions,
): Promise<void> {
this.running = true;

while (this.running) {
try {
// Resolve inside the loop so a gateway-URL resolution failure also
// routes to `onError` and backs off (not just poll failures). It
// caches on success, so this is a cheap guard after the first poll.
const gatewayUrl = await this.resolveGatewayUrl();
const poll = await this.poll(gatewayUrl);

for (const request of poll.requests) {
this.inFlight.add(request.id);
request.timeoutSeconds = poll.timeoutSeconds;

this.handleRequest(gatewayUrl, request, callback);
}
} catch (error) {
if (!this.running) return;
options?.onError?.(error);
await this.sleep(5000);
}
}
}

/**
* Process a single approval: call the callback, submit the decision back to
* the request's own project. Runs independently (concurrent). On any failure,
* removes from inFlight so the next poll retries.
*/
private handleRequest(
gatewayUrl: string,
request: OrgApprovalRequest,
callback: OrgManualApprovalCallback,
): void {
(async () => {
try {
const decision = await callback(request);
await this.submitDecision(
gatewayUrl,
request.id,
decision,
request.projectId,
);
} finally {
this.inFlight.delete(request.id);
}
})().catch(() => {
this.inFlight.delete(request.id);
});
}

/** Stop the polling loop and abort any in-flight poll request. */
stop(): void {
this.running = false;
this.abortController?.abort();
}

/**
* Long-poll the gateway for pending approvals across the org.
* Server holds up to 30s; we set a 35s client timeout.
*/
private async poll(gatewayUrl: string): Promise<OrgPollResponse> {
this.abortController = new AbortController();

let url = `${gatewayUrl}/v1/org/approvals/pending`;
if (this.inFlight.size > 0) {
const exclude = [...this.inFlight].join(",");
url += `?exclude=${encodeURIComponent(exclude)}`;
}
const res = await fetch(url, {
headers: this.buildAuthHeaders(),
signal: AbortSignal.any([
this.abortController.signal,
AbortSignal.timeout(35_000),
]),
});

if (!res.ok) {
throw new OneCLIRequestError("Org approval poll failed", {
url,
statusCode: res.status,
});
}

return (await res.json()) as OrgPollResponse;
}

/** Submit a decision for a single approval, scoped to its own project. */
private async submitDecision(
gatewayUrl: string,
id: string,
decision: string,
projectId: string,
): Promise<void> {
const url = `${gatewayUrl}/v1/approvals/${encodeURIComponent(id)}/decision`;

const headers = this.buildAuthHeaders(projectId);
headers["Content-Type"] = "application/json";

const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({ decision }),
signal: AbortSignal.timeout(5000),
});

if (!res.ok && res.status !== 410) {
throw new OneCLIRequestError("Decision submission failed", {
url,
statusCode: res.status,
});
}
}

private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
29 changes: 29 additions & 0 deletions src/approvals/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,32 @@ export interface ManualApprovalHandle {
/** Stop polling and disconnect. */
stop: () => void;
}

/**
* An approval request from the organization-scoped poll. Identical to
* {@link ApprovalRequest} but carries the `projectId` of the project it belongs
* to — a single org handler spans every project, so each decision is routed
* back to the right one.
*/
export interface OrgApprovalRequest extends ApprovalRequest {
/** The project this request belongs to. */
projectId: string;
}

/**
* Callback invoked once per organization-scoped approval request. Like
* {@link ManualApprovalCallback}, but the request carries its `projectId`.
*/
export type OrgManualApprovalCallback = (
request: OrgApprovalRequest,
) => Promise<"approve" | "deny">;

/** Options for `onecli.org.configureManualApproval()`. */
export interface OrgManualApprovalOptions {
/**
* Called when a poll cycle fails (auth, network, or gateway-URL resolution).
* The loop backs off and retries either way; without a handler these errors
* are swallowed silently.
*/
onError?: (error: unknown) => void;
}
2 changes: 1 addition & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export class OneCLI {
timeout,
projectId,
);
this.org = new OrgClient(url, apiKey, timeout);
this.org = new OrgClient(url, apiKey, timeout, gatewayUrl);
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { OneCLI } from "./client.js";
export { ContainerClient } from "./container/index.js";
export { AgentsClient } from "./agents/index.js";
export { ApprovalClient } from "./approvals/index.js";
export { OrgApprovalClient } from "./approvals/org.js";
export { ProvisionClient } from "./provisions/index.js";
export { OrgClient } from "./org/index.js";
export { OneCLIError, OneCLIRequestError } from "./errors.js";
Expand All @@ -26,6 +27,9 @@ export type {
ApprovalDetail,
ManualApprovalCallback,
ManualApprovalHandle,
OrgApprovalRequest,
OrgManualApprovalCallback,
OrgManualApprovalOptions,
} from "./approvals/types.js";
export type {
ProvisionProjectInput,
Expand Down
40 changes: 39 additions & 1 deletion src/org/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import {
OneCLIRequestError,
toOneCLIError,
} from "../errors.js";
import { OrgApprovalClient } from "../approvals/org.js";
import type {
ManualApprovalHandle,
OrgManualApprovalCallback,
OrgManualApprovalOptions,
} from "../approvals/types.js";
import type {
ConnectOrgAppInput,
CreateOrgRuleInput,
Expand Down Expand Up @@ -51,11 +57,18 @@ export class OrgClient {
private baseUrl: string;
private apiKey: string;
private timeout: number;
private gatewayUrl: string | null;

constructor(baseUrl: string, apiKey: string, timeout: number) {
constructor(
baseUrl: string,
apiKey: string,
timeout: number,
gatewayUrl: string | null = null,
) {
this.baseUrl = baseUrl.replace(/\/+$/, "");
this.apiKey = apiKey;
this.timeout = timeout;
this.gatewayUrl = gatewayUrl;
}

private buildHeaders(): Record<string, string> {
Expand Down Expand Up @@ -274,4 +287,29 @@ export class OrgClient {
`/v1/org/rules/${encodeURIComponent(ruleId)}`,
);
};

/**
* Register a callback for manual approval requests across **every** project
* in the organization. Starts background long-polling to the gateway; the
* callback is invoked once per pending request (concurrently), each carrying
* its `projectId`, and each decision is routed back to that project. Returns
* a handle to stop polling when shutting down.
*
* Requires an organization API key (`oc_org_...`) and OneCLI Cloud or a
* self-hosted Enterprise instance.
*/
configureManualApproval = (
callback: OrgManualApprovalCallback,
options?: OrgManualApprovalOptions,
): ManualApprovalHandle => {
const client = new OrgApprovalClient(
this.baseUrl,
this.apiKey,
this.gatewayUrl,
);
client.start(callback, options).catch(() => {
// Poll errors surface via options.onError and retry with backoff.
});
return { stop: () => client.stop() };
};
}
Loading
Loading