Initial commit — @crema/integration-registry-client

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) <noreply@anthropic.com>
This commit is contained in:
Giuliano Silvestro
2026-06-09 21:25:29 +10:00
commit ac9f1fa7c2
4 changed files with 240 additions and 0 deletions

198
src/index.tsx Normal file
View File

@@ -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<string, unknown>
/** 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<string, unknown>
}
/** 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<Integration[]>
create(input: IntegrationInput): Promise<Integration>
update(id: string, input: Partial<IntegrationInput>): Promise<Integration>
remove(id: string): Promise<void>
addCredential(integrationId: string, input: CredentialInput): Promise<Credential>
updateCredential(credentialId: string, input: Partial<CredentialInput>): Promise<Credential>
deleteCredential(credentialId: string): Promise<void>
/** Runs the registry's policy gate; throws ArcadiaError on 409/429/404. */
test(id: string): Promise<TestVerdict>
usage(filter?: ScopeFilter): Promise<UsageEntry[]>
}
/**
* 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<TestVerdict>(`${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"
}