From 72c4505cf81de674ab5a93281c66cc5f8b78d527 Mon Sep 17 00:00:00 2001 From: Guy Ben Aharon Date: Mon, 6 Jul 2026 12:02:30 -0700 Subject: [PATCH] feat: org-scoped manual approval handler (onecli.org.configureManualApproval) Adds OrgApprovalClient, a cross-project sibling of the manual-approval handler: it polls GET /v1/org/approvals/pending with an organization API key (no X-Project-Id), and routes each decision back to the request's own project via the existing POST /v1/approvals/{id}/decision route with X-Project-Id. Additive and backward-compatible - the project ApprovalClient is unchanged. An onError hook surfaces poll failures (including gateway-URL resolution) instead of swallowing them. Co-Authored-By: Claude Opus 4.8 --- README.md | 15 +++ src/approvals/org.ts | 206 +++++++++++++++++++++++++++++++++++++ src/approvals/types.ts | 29 ++++++ src/client.ts | 2 +- src/index.ts | 4 + src/org/index.ts | 40 ++++++- test/approvals/org.test.ts | 174 +++++++++++++++++++++++++++++++ 7 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 src/approvals/org.ts create mode 100644 test/approvals/org.test.ts diff --git a/README.md b/README.md index 320743d..6f1938e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` | --- @@ -452,6 +464,9 @@ import type { ApprovalRequest, ManualApprovalCallback, ManualApprovalHandle, + OrgApprovalRequest, + OrgManualApprovalCallback, + OrgManualApprovalOptions, ProvisionProjectInput, ProvisionProjectResponse, ConnectOrgAppInput, diff --git a/src/approvals/org.ts b/src/approvals/org.ts new file mode 100644 index 0000000..21757f8 --- /dev/null +++ b/src/approvals/org.ts @@ -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(); + + 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 { + const headers: Record = { + 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 { + 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 { + 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 { + 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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/approvals/types.ts b/src/approvals/types.ts index 08e84fc..6b46ffc 100644 --- a/src/approvals/types.ts +++ b/src/approvals/types.ts @@ -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; +} diff --git a/src/client.ts b/src/client.ts index c6d4903..9d5e492 100644 --- a/src/client.ts +++ b/src/client.ts @@ -70,7 +70,7 @@ export class OneCLI { timeout, projectId, ); - this.org = new OrgClient(url, apiKey, timeout); + this.org = new OrgClient(url, apiKey, timeout, gatewayUrl); } /** diff --git a/src/index.ts b/src/index.ts index 292e69c..3feb546 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -26,6 +27,9 @@ export type { ApprovalDetail, ManualApprovalCallback, ManualApprovalHandle, + OrgApprovalRequest, + OrgManualApprovalCallback, + OrgManualApprovalOptions, } from "./approvals/types.js"; export type { ProvisionProjectInput, diff --git a/src/org/index.ts b/src/org/index.ts index 6c65869..3452a3e 100644 --- a/src/org/index.ts +++ b/src/org/index.ts @@ -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, @@ -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 { @@ -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() }; + }; } diff --git a/test/approvals/org.test.ts b/test/approvals/org.test.ts new file mode 100644 index 0000000..9d4a698 --- /dev/null +++ b/test/approvals/org.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { OrgApprovalClient } from "../../src/approvals/org.js"; +import { OneCLIRequestError } from "../../src/index.js"; +import type { OrgApprovalRequest } from "../../src/approvals/types.js"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +const makeRequest = ( + overrides?: Partial, +): OrgApprovalRequest => ({ + id: "ap-1", + projectId: "proj-1", + method: "POST", + url: "https://api.example.com/v1/send", + host: "api.example.com", + path: "/v1/send", + headers: {}, + bodyPreview: null, + agent: { id: "a-1", name: "Agent", externalId: null }, + createdAt: "2026-01-01T00:00:00Z", + expiresAt: "2026-01-01T00:05:00Z", + timeoutSeconds: 300, + ...overrides, +}); + +const pollResponse = (requests: OrgApprovalRequest[]): Response => + new Response(JSON.stringify({ requests, timeoutSeconds: 300 }), { + status: 200, + }); + +/** A fetch that stays pending until its request is aborted, then rejects — so + * the poll loop parks on the second poll instead of busy-spinning. */ +const hangUntilAborted = (init?: RequestInit): Promise => + new Promise((_resolve, reject) => { + const abort = () => + reject(new DOMException("The operation was aborted.", "AbortError")); + const signal = init?.signal ?? undefined; + if (signal?.aborted) return abort(); + signal?.addEventListener("abort", abort); + }); + +const headersOf = (call: [unknown, unknown]): Record => + (call[1] as { headers: Record }).headers; + +describe("OrgApprovalClient", () => { + it("polls the org endpoint with bearer auth and no project header, then routes the decision to the request's own project", async () => { + const request = makeRequest({ id: "ap-9", projectId: "proj-42" }); + let polls = 0; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation((input, init) => { + if (String(input).includes("/v1/org/approvals/pending")) { + polls += 1; + return polls === 1 + ? Promise.resolve(pollResponse([request])) + : hangUntilAborted(init as RequestInit); + } + // decision endpoint + return Promise.resolve(new Response(null, { status: 200 })); + }); + + const client = new OrgApprovalClient( + "http://localhost:3000", + "oc_org_key", + "http://gw.local", + ); + + let seenProjectId: string | undefined; + const started = client.start(async (req) => { + seenProjectId = req.projectId; + return "approve"; + }); + + await vi.waitFor(() => { + expect( + fetchSpy.mock.calls.some(([u]) => String(u).includes("/decision")), + ).toBe(true); + }); + + client.stop(); + await started; + + // The callback saw the request's own project. + expect(seenProjectId).toBe("proj-42"); + + // Poll: org endpoint, bearer token, and crucially no X-Project-Id. + const pollCall = fetchSpy.mock.calls.find(([u]) => + String(u).includes("/pending"), + ) as [unknown, unknown]; + expect(String(pollCall[0])).toBe( + "http://gw.local/v1/org/approvals/pending", + ); + expect(headersOf(pollCall).Authorization).toBe("Bearer oc_org_key"); + expect(headersOf(pollCall)).not.toHaveProperty("X-Project-Id"); + + // Decision: reuses the project decision route, scoped by X-Project-Id. + const decisionCall = fetchSpy.mock.calls.find(([u]) => + String(u).includes("/decision"), + ) as [unknown, unknown]; + expect(String(decisionCall[0])).toBe( + "http://gw.local/v1/approvals/ap-9/decision", + ); + expect(decisionCall[1]).toMatchObject({ + method: "POST", + body: JSON.stringify({ decision: "approve" }), + }); + expect(headersOf(decisionCall)["X-Project-Id"]).toBe("proj-42"); + }); + + it("passes poll failures to the onError hook instead of swallowing them", async () => { + vi.useFakeTimers(); + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("unauthorized", { status: 401 }), + ); + + const errors: unknown[] = []; + const client = new OrgApprovalClient( + "http://localhost:3000", + "oc_org_key", + "http://gw.local", + ); + const started = client.start(async () => "approve", { + onError: (error) => errors.push(error), + }); + + // Flush the first (failing) poll and the onError callback. + await vi.advanceTimersByTimeAsync(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(OneCLIRequestError); + + // Stop, then drain the 5s backoff so the loop exits cleanly. + client.stop(); + await vi.advanceTimersByTimeAsync(5000); + await started; + }); + + it("routes a gateway-URL resolution failure to onError (not just poll failures)", async () => { + vi.useFakeTimers(); + + // No preset gateway URL → the client must GET /v1/gateway-url first, which + // fails. This must surface via onError and retry, not be swallowed. + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("boom", { status: 500 })); + + const errors: unknown[] = []; + const client = new OrgApprovalClient( + "http://localhost:3000", + "oc_org_key", + null, + ); + const started = client.start(async () => "approve", { + onError: (error) => errors.push(error), + }); + + await vi.advanceTimersByTimeAsync(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(OneCLIRequestError); + // It failed at resolution — the poll endpoint was never reached. + expect( + fetchSpy.mock.calls.every(([u]) => + String(u).includes("/v1/gateway-url"), + ), + ).toBe(true); + + client.stop(); + await vi.advanceTimersByTimeAsync(5000); + await started; + }); +});