From ac9f1fa7c2a1a6391cda70cf754e8d6361b767a3 Mon Sep 17 00:00:00 2001 From: Giuliano Silvestro Date: Tue, 9 Jun 2026 21:25:29 +1000 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20@crema/integrati?= =?UTF-8?q?on-registry-client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typed client + types for the arcadia integration registry (on arcadia-llm-gateway). One createIntegrationsApi(client, mode) factory serves both surfaces: - operator → /api/v1/integrations* (any scope, scope filters) - tenant → /api/v1/me/integrations* (scope forced server-side) Plus shared types (Integration/Credential/UsageEntry/*Input/TestVerdict) and display helpers (formatUsd, credentialHealth). Consumed by arcadia-admin and arcadia-console as a sibling lib via vite/tsconfig aliases (no build step). Secrets are write-only; the rich panels stay per-app. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 + README.md | 26 +++++++ package.json | 14 ++++ src/index.tsx | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 src/index.tsx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2752eb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f78d73 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# @crema/integration-registry-client + +Typed client + types for the **arcadia integration registry** — the external-API +credential plane hosted on `arcadia-llm-gateway`. Consumed by `arcadia-admin` +(operator) and `arcadia-console` (tenant) as a sibling lib via vite/tsconfig +aliases (no build step — the app compiles `src/` directly). + +```ts +import { createIntegrationsApi } from "@crema/integration-registry-client" + +// gateway-pointed ArcadiaClient (see each app's app/lib/gateway.ts) +const api = createIntegrationsApi(gatewayClient, "tenant") // or "operator" + +await api.list() +await api.create({ provider: "tavily", capability: "web_search" }) +await api.test(id) // throws ArcadiaError on 409 expired / 429 over-budget +``` + +- `operator` mode → `/api/v1/integrations*` (any scope, scope filters honoured). +- `tenant` mode → `/api/v1/me/integrations*` (scope forced to the caller server-side). + +Secrets are **write-only**: reads carry `has_secret`, never a value. + +The rich management **page/components stay per-app** (they use each app's shadcn +primitives and the operator/tenant surfaces legitimately diverge) — this lib owns +the client, types, and display helpers only. diff --git a/package.json b/package.json new file mode 100644 index 0000000..0e79f70 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "@crema/integration-registry-client", + "version": "0.1.0", + "description": "Typed client + types for the arcadia integration registry (on arcadia-llm-gateway). One factory serves the operator and tenant surfaces.", + "type": "module", + "main": "src/index.tsx", + "types": "src/index.tsx", + "exports": { + ".": "./src/index.tsx" + }, + "peerDependencies": { + "@crema/arcadia-client": "*" + } +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..8e80bae --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,198 @@ +"use client" + +// PURPOSE: Typed client + types for the arcadia integration registry (hosted +// on arcadia-llm-gateway). One factory serves both the operator surface +// (/api/v1/integrations*) and the tenant surface (/api/v1/me/integrations*), +// so arcadia-admin and arcadia-console share a single contract. Secrets are +// write-only end to end: reads carry `has_secret`, never a value. +// +// EXPORTS: types (Integration, Credential, UsageEntry, *Input, TestVerdict, …), +// createIntegrationsApi(client, mode), formatUsd, credentialHealth. + +import type { ArcadiaClient } from "@crema/arcadia-client" + +export type IntegrationsMode = "operator" | "tenant" +export type Scope = "platform" | "tenant" | "app" | "user" | "agent" +export type AuthKind = "bearer_static" | "basic" | "api_key_header" | "oauth2" +export type CredentialSource = "byo" | "pooled" +export type CredentialStatus = "active" | "expired" | "revoked" + +export interface CostModel { + unit?: "call" | "search" | "1k_tokens" + price_usd?: string | number + currency?: string +} + +export interface Constraints { + rate_per_min?: number + monthly_budget_usd?: string | number + monthly_call_cap?: number +} + +export interface Credential { + id: string + integration_id: string + secret_name: string + auth_kind: AuthKind + meta: Record + /** Presence, not value — the secret is write-only. */ + has_secret: boolean + expires_at: string | null + last_rotated_at: string | null + source: CredentialSource + status: CredentialStatus +} + +export interface Integration { + id: string + scope: Scope + scope_id: string | null + provider: string + capability: string | null + display_name: string | null + description: string | null + docs_url: string | null + base_url: string | null + enabled: boolean + cost_model: CostModel + constraints: Constraints + created_by: string | null + credentials: Credential[] +} + +export interface UsageEntry { + integration_id: string + scope: Scope + scope_id: string | null + provider: string + capability: string | null + units: number + cost_usd: string + calls: number +} + +export interface IntegrationInput { + /** Operator-only — the tenant surface forces scope=tenant + the caller's id. */ + scope?: Scope + scope_id?: string | null + provider: string + capability?: string | null + display_name?: string | null + description?: string | null + docs_url?: string | null + base_url?: string | null + enabled?: boolean + cost_model?: CostModel + constraints?: Constraints +} + +export interface CredentialInput { + secret_name: string + auth_kind: AuthKind + /** Plaintext, write-only — sent on create/rotate, never returned. */ + secret?: string + source?: CredentialSource + expires_at?: string | null + meta?: Record +} + +/** Verdict from the "Test" gate-check (resolve + policy, no live upstream). */ +export interface TestVerdict { + status: string + auth_kind?: AuthKind + policy?: { + within_budget: boolean + within_rate: boolean + remaining_budget_usd: string | null + } +} + +/** Operator-only list/usage filter; ignored on the tenant surface. */ +export interface ScopeFilter { + scope?: Scope + scope_id?: string +} + +export interface IntegrationsApi { + list(filter?: ScopeFilter): Promise + create(input: IntegrationInput): Promise + update(id: string, input: Partial): Promise + remove(id: string): Promise + addCredential(integrationId: string, input: CredentialInput): Promise + updateCredential(credentialId: string, input: Partial): Promise + deleteCredential(credentialId: string): Promise + /** Runs the registry's policy gate; throws ArcadiaError on 409/429/404. */ + test(id: string): Promise + usage(filter?: ScopeFilter): Promise +} + +/** + * Build a registry client over a gateway-pointed `ArcadiaClient`. + * + * `mode` selects the surface: `operator` → `/api/v1/integrations*` (any scope, + * scope filters honoured); `tenant` → `/api/v1/me/integrations*` (scope forced + * to the caller's tenant server-side, filters ignored). + */ +export function createIntegrationsApi(c: ArcadiaClient, mode: IntegrationsMode): IntegrationsApi { + const base = mode === "operator" ? "/api/v1/integrations" : "/api/v1/me/integrations" + const credBase = mode === "operator" ? "/api/v1/credentials" : "/api/v1/me/credentials" + // Scope filters only mean something on the operator surface. + const params = (f?: ScopeFilter) => + mode === "operator" && f ? { scope: f.scope, scope_id: f.scope_id } : undefined + + return { + async list(filter) { + const r = await c.GET<{ integrations: Integration[] }>(base, { params: params(filter) }) + return r.integrations + }, + async create(input) { + const r = await c.POST<{ integration: Integration }>(base, { body: input }) + return r.integration + }, + async update(id, input) { + const r = await c.PATCH<{ integration: Integration }>(`${base}/${id}`, { body: input }) + return r.integration + }, + async remove(id) { + await c.DELETE(`${base}/${id}`) + }, + async addCredential(integrationId, input) { + const r = await c.POST<{ credential: Credential }>(`${base}/${integrationId}/credentials`, { + body: input, + }) + return r.credential + }, + async updateCredential(credentialId, input) { + const r = await c.PATCH<{ credential: Credential }>(`${credBase}/${credentialId}`, { + body: input, + }) + return r.credential + }, + async deleteCredential(credentialId) { + await c.DELETE(`${credBase}/${credentialId}`) + }, + async test(id) { + return c.POST(`${base}/${id}/test`) + }, + async usage(filter) { + const r = await c.GET<{ usage: UsageEntry[] }>(`${base}/usage`, { params: params(filter) }) + return r.usage + }, + } +} + +// ── display helpers ──────────────────────────────────────────────────── + +export function formatUsd(value: string | number | null | undefined): string { + if (value === null || value === undefined) return "—" + const n = typeof value === "string" ? Number(value) : value + if (Number.isNaN(n)) return "—" + return `$${n.toFixed(n < 0.01 && n > 0 ? 4 : 2)}` +} + +export function credentialHealth(cred: Credential): "ok" | "expired" | "revoked" | "missing" { + if (cred.status === "revoked") return "revoked" + if (!cred.has_secret) return "missing" + if (cred.expires_at && new Date(cred.expires_at).getTime() < Date.now()) return "expired" + return "ok" +}