Wire operator Integrations page + capability-gating framework
Completes the arcadia-admin operator surface for the integration registry and the capability/route-guard framework it depends on. - Integration registry: route + Data-group nav entry + `platform.integrations` capability; the in-app client now delegates to the shared `@crema/integration-registry-client` lib (vite alias + tsconfig); the operator Integrations page (committed earlier) is now reachable. - Capability gating: capabilities map + route-guard + jwt helpers + the apps/plan/entitlements routes and supporting tenants/session changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,205 +1,40 @@
|
||||
// Integration-registry API client (operator surface).
|
||||
//
|
||||
// Talks to arcadia-llm-gateway's `/api/v1/integrations*` endpoints — the
|
||||
// platform operator manages pooled/platform credentials at ANY scope and
|
||||
// inspects cross-tenant usage *metadata* (never secrets; reads return
|
||||
// `has_secret`, never a value).
|
||||
//
|
||||
// The tenant mirror (`/api/v1/me/integrations*`, scoped to the caller) lives
|
||||
// in arcadia-console. Pure functions over an injected gateway `ArcadiaClient`
|
||||
// (see `~/lib/gateway`); no app coupling, so this lifts into a shared lib once
|
||||
// both apps exist.
|
||||
// Integration-registry client (operator surface) — thin shim over the shared
|
||||
// `@crema/integration-registry-client` lib, bound to `operator` mode. The lib
|
||||
// owns the types, the HTTP contract, and the display helpers (shared with
|
||||
// arcadia-console's tenant surface); this file just exposes operator-idiomatic
|
||||
// names so the page reads naturally.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
import {
|
||||
createIntegrationsApi,
|
||||
type CredentialInput,
|
||||
type IntegrationInput,
|
||||
type ScopeFilter,
|
||||
} from "@crema/integration-registry-client"
|
||||
|
||||
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"
|
||||
// Re-export the shared types + helpers so callers import from one place.
|
||||
export * from "@crema/integration-registry-client"
|
||||
|
||||
export interface CostModel {
|
||||
unit?: "call" | "search" | "1k_tokens"
|
||||
price_usd?: string | number
|
||||
currency?: string
|
||||
}
|
||||
const op = (c: ArcadiaClient) => createIntegrationsApi(c, "operator")
|
||||
|
||||
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 {
|
||||
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>
|
||||
}
|
||||
|
||||
export interface TestVerdict {
|
||||
status: string
|
||||
auth_kind?: AuthKind
|
||||
policy?: {
|
||||
within_budget: boolean
|
||||
within_rate: boolean
|
||||
remaining_budget_usd: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScopeFilter {
|
||||
scope?: Scope
|
||||
scope_id?: string
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/integrations"
|
||||
|
||||
export async function listIntegrations(
|
||||
c: ArcadiaClient,
|
||||
filter: ScopeFilter = {},
|
||||
): Promise<Integration[]> {
|
||||
const res = await c.GET<{ integrations: Integration[] }>(BASE, {
|
||||
params: { scope: filter.scope, scope_id: filter.scope_id },
|
||||
})
|
||||
return res.integrations
|
||||
}
|
||||
|
||||
export async function createIntegration(
|
||||
c: ArcadiaClient,
|
||||
input: IntegrationInput,
|
||||
): Promise<Integration> {
|
||||
const res = await c.POST<{ integration: Integration }>(BASE, { body: input })
|
||||
return res.integration
|
||||
}
|
||||
|
||||
export async function updateIntegration(
|
||||
export const listIntegrations = (c: ArcadiaClient, filter: ScopeFilter = {}) =>
|
||||
op(c).list(filter)
|
||||
export const createIntegration = (c: ArcadiaClient, input: IntegrationInput) =>
|
||||
op(c).create(input)
|
||||
export const updateIntegration = (
|
||||
c: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<IntegrationInput>,
|
||||
): Promise<Integration> {
|
||||
const res = await c.PATCH<{ integration: Integration }>(`${BASE}/${id}`, { body: input })
|
||||
return res.integration
|
||||
}
|
||||
|
||||
export async function deleteIntegration(c: ArcadiaClient, id: string): Promise<void> {
|
||||
await c.DELETE(`${BASE}/${id}`)
|
||||
}
|
||||
|
||||
export async function addCredential(
|
||||
c: ArcadiaClient,
|
||||
integrationId: string,
|
||||
input: CredentialInput,
|
||||
): Promise<Credential> {
|
||||
const res = await c.POST<{ credential: Credential }>(`${BASE}/${integrationId}/credentials`, {
|
||||
body: input,
|
||||
})
|
||||
return res.credential
|
||||
}
|
||||
|
||||
export async function updateCredential(
|
||||
) => op(c).update(id, input)
|
||||
export const deleteIntegration = (c: ArcadiaClient, id: string) => op(c).remove(id)
|
||||
export const addCredential = (c: ArcadiaClient, integrationId: string, input: CredentialInput) =>
|
||||
op(c).addCredential(integrationId, input)
|
||||
export const updateCredential = (
|
||||
c: ArcadiaClient,
|
||||
credentialId: string,
|
||||
input: Partial<CredentialInput>,
|
||||
): Promise<Credential> {
|
||||
const res = await c.PATCH<{ credential: Credential }>(`/api/v1/credentials/${credentialId}`, {
|
||||
body: input,
|
||||
})
|
||||
return res.credential
|
||||
}
|
||||
|
||||
export async function deleteCredential(c: ArcadiaClient, credentialId: string): Promise<void> {
|
||||
await c.DELETE(`/api/v1/credentials/${credentialId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the registry's policy gate against an integration's credential. 200 with
|
||||
* a verdict; 4xx (409 expired / 429 over-budget / 404 no credential) is thrown
|
||||
* by `ArcadiaClient` as an `ArcadiaError` — inspect `err.status`.
|
||||
*/
|
||||
export async function testIntegration(c: ArcadiaClient, id: string): Promise<TestVerdict> {
|
||||
return c.POST<TestVerdict>(`${BASE}/${id}/test`)
|
||||
}
|
||||
|
||||
export async function usageSummary(
|
||||
c: ArcadiaClient,
|
||||
filter: ScopeFilter = {},
|
||||
): Promise<UsageEntry[]> {
|
||||
const res = await c.GET<{ usage: UsageEntry[] }>(`${BASE}/usage`, {
|
||||
params: { scope: filter.scope, scope_id: filter.scope_id },
|
||||
})
|
||||
return res.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"
|
||||
}
|
||||
) => op(c).updateCredential(credentialId, input)
|
||||
export const deleteCredential = (c: ArcadiaClient, credentialId: string) =>
|
||||
op(c).deleteCredential(credentialId)
|
||||
export const testIntegration = (c: ArcadiaClient, id: string) => op(c).test(id)
|
||||
export const usageSummary = (c: ArcadiaClient, filter: ScopeFilter = {}) => op(c).usage(filter)
|
||||
|
||||
@@ -91,3 +91,23 @@ export async function deactivateTenant(arcadia: ArcadiaClient, id: string): Prom
|
||||
const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/deactivate`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export interface ProvisionTenantInput {
|
||||
tenant: { name: string; slug: string }
|
||||
admin_user: {
|
||||
email: string
|
||||
password: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function provisionTenant(
|
||||
arcadia: ArcadiaClient,
|
||||
input: ProvisionTenantInput,
|
||||
): Promise<Tenant> {
|
||||
const res = await arcadia.POST<{ data: Tenant }>("/api/v1/admin/tenants/provision", {
|
||||
body: input,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
168
app/lib/capabilities.ts
Normal file
168
app/lib/capabilities.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// Capability gating — the contract between roles, nav, and routes.
|
||||
//
|
||||
// A capability is a *thing the user can do in this UI*. The set held by
|
||||
// the current session is computed from their active membership's roles
|
||||
// + the slug of the active tenant (platform-admin gets the platform.*
|
||||
// fleet by default). Sidebar nav filters by it; per-route guards 403
|
||||
// when the user deep-links to one they don't hold.
|
||||
//
|
||||
// The server is the real authority — these checks are UI-shaping, not
|
||||
// security. Don't ever trust the client capability check on its own.
|
||||
|
||||
export type Capability =
|
||||
// tenant.* — held by tenant_admin on the active membership.
|
||||
| "tenant.home"
|
||||
| "tenant.users"
|
||||
| "tenant.invitations"
|
||||
| "tenant.roles"
|
||||
| "tenant.memberships"
|
||||
| "tenant.apps"
|
||||
| "tenant.plan"
|
||||
| "tenant.entitlements"
|
||||
| "tenant.storage"
|
||||
| "tenant.buckets"
|
||||
| "tenant.activity"
|
||||
| "tenant.settings"
|
||||
| "tenant.profile"
|
||||
// platform.* — held by platform_admin on platform-admin.
|
||||
| "platform.tenants"
|
||||
| "platform.organizations"
|
||||
| "platform.networking"
|
||||
| "platform.monitoring"
|
||||
| "platform.status_page"
|
||||
| "platform.scheduled_tasks"
|
||||
| "platform.secrets"
|
||||
| "platform.webhooks"
|
||||
| "platform.announcements"
|
||||
| "platform.sso"
|
||||
| "platform.library"
|
||||
| "platform.search"
|
||||
| "platform.ai"
|
||||
| "platform.integrations" // external-API registry (keys/budgets) on the gateway
|
||||
// Special — always-on; not gated.
|
||||
| "always.assistant"
|
||||
| "always.profile"
|
||||
|
||||
/** Roles arcadia issues that this UI knows about. */
|
||||
export type Role =
|
||||
| "platform_admin"
|
||||
| "tenant_admin"
|
||||
| "member"
|
||||
| (string & {}) // accept unknown roles forward-compat
|
||||
|
||||
const TENANT_ADMIN_CAPS: Capability[] = [
|
||||
"tenant.home",
|
||||
"tenant.users",
|
||||
"tenant.invitations",
|
||||
"tenant.roles",
|
||||
"tenant.memberships",
|
||||
"tenant.apps",
|
||||
"tenant.plan",
|
||||
"tenant.entitlements",
|
||||
"tenant.storage",
|
||||
"tenant.buckets",
|
||||
"tenant.activity",
|
||||
"tenant.settings",
|
||||
"tenant.profile",
|
||||
]
|
||||
|
||||
const PLATFORM_ADMIN_CAPS: Capability[] = [
|
||||
// platform_admin also gets every tenant.* — they're an admin of the
|
||||
// platform-admin tenant, so they manage *its* users, storage, etc.
|
||||
...TENANT_ADMIN_CAPS,
|
||||
"platform.tenants",
|
||||
"platform.organizations",
|
||||
"platform.networking",
|
||||
"platform.monitoring",
|
||||
"platform.status_page",
|
||||
"platform.scheduled_tasks",
|
||||
"platform.secrets",
|
||||
"platform.webhooks",
|
||||
"platform.announcements",
|
||||
"platform.sso",
|
||||
"platform.library",
|
||||
"platform.search",
|
||||
"platform.ai",
|
||||
"platform.integrations",
|
||||
]
|
||||
|
||||
const ALWAYS_CAPS: Capability[] = ["always.assistant", "always.profile"]
|
||||
|
||||
export function capabilitiesForRoles(roles: readonly string[] | undefined): Set<Capability> {
|
||||
const caps = new Set<Capability>(ALWAYS_CAPS)
|
||||
const has = (r: string) => (roles ?? []).includes(r)
|
||||
if (has("platform_admin")) PLATFORM_ADMIN_CAPS.forEach((c) => caps.add(c))
|
||||
if (has("tenant_admin") || has("admin")) TENANT_ADMIN_CAPS.forEach((c) => caps.add(c))
|
||||
// "member" / other roles get only the always-on set.
|
||||
return caps
|
||||
}
|
||||
|
||||
/** Pure helper — handy in tests + route loaders. */
|
||||
export function holds(caps: Set<Capability>, cap: Capability): boolean {
|
||||
return caps.has(cap)
|
||||
}
|
||||
|
||||
// ----------------------------- Route map ----------------------------
|
||||
//
|
||||
// Every protected route declares which capability it needs. Sidebar nav
|
||||
// and the per-route guard both read this map, so the contract lives in
|
||||
// one place.
|
||||
|
||||
export const ROUTE_CAPABILITY: Record<string, Capability> = {
|
||||
"/": "tenant.home",
|
||||
"/users": "tenant.users",
|
||||
"/memberships": "tenant.memberships",
|
||||
"/storage": "tenant.storage",
|
||||
"/buckets": "tenant.buckets",
|
||||
"/activity": "tenant.activity",
|
||||
"/settings": "tenant.settings",
|
||||
"/apps": "tenant.apps",
|
||||
"/plan": "tenant.plan",
|
||||
"/entitlements": "tenant.entitlements",
|
||||
|
||||
"/tenants": "platform.tenants",
|
||||
"/organizations": "platform.organizations",
|
||||
"/networking": "platform.networking",
|
||||
"/monitoring": "platform.monitoring",
|
||||
"/status-page": "platform.status_page",
|
||||
"/scheduled-tasks": "platform.scheduled_tasks",
|
||||
"/secrets": "platform.secrets",
|
||||
"/webhooks": "platform.webhooks",
|
||||
"/announcements": "platform.announcements",
|
||||
"/sso": "platform.sso",
|
||||
"/library": "platform.library",
|
||||
"/search": "platform.search",
|
||||
"/ai": "platform.ai",
|
||||
"/integrations": "platform.integrations",
|
||||
|
||||
"/assistant": "always.assistant",
|
||||
"/profile": "always.profile",
|
||||
}
|
||||
|
||||
// ----------------------------- Hooks --------------------------------
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useSession } from "~/lib/session"
|
||||
|
||||
/** The active session's capability set. Empty when not signed in. */
|
||||
export function useCapabilities(): Set<Capability> {
|
||||
const session = useSession()
|
||||
return useMemo(() => capabilitiesForRoles(session?.roles), [session?.roles])
|
||||
}
|
||||
|
||||
export function useHasCapability(cap: Capability): boolean {
|
||||
return useCapabilities().has(cap)
|
||||
}
|
||||
|
||||
export function capabilityForPath(pathname: string): Capability | null {
|
||||
// Exact match first.
|
||||
if (ROUTE_CAPABILITY[pathname]) return ROUTE_CAPABILITY[pathname]
|
||||
// Then prefix match — "/users/123" inherits "/users"'s capability.
|
||||
// Walk known keys longest-first so "/scheduled-tasks/x" picks the
|
||||
// right one over "/s".
|
||||
const keys = Object.keys(ROUTE_CAPABILITY).sort((a, b) => b.length - a.length)
|
||||
for (const k of keys) {
|
||||
if (k !== "/" && pathname.startsWith(k + "/")) return ROUTE_CAPABILITY[k]
|
||||
}
|
||||
return null
|
||||
}
|
||||
49
app/lib/jwt.ts
Normal file
49
app/lib/jwt.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Tiny JWT helpers — we never *verify* tokens client-side (the server
|
||||
// is the only authority), we just decode the payload to read claims
|
||||
// the UI uses for nav gating + tenant context.
|
||||
|
||||
export type ArcadiaClaims = {
|
||||
sub?: string
|
||||
email?: string
|
||||
tenant_id?: string
|
||||
tenant_slug?: string
|
||||
roles?: string[]
|
||||
available_tenants?: AvailableTenantClaim[]
|
||||
exp?: number
|
||||
iat?: number
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export type AvailableTenantClaim = {
|
||||
id?: string
|
||||
slug?: string
|
||||
name?: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string): string {
|
||||
const pad = "=".repeat((4 - (s.length % 4)) % 4)
|
||||
const b64 = (s + pad).replace(/-/g, "+").replace(/_/g, "/")
|
||||
if (typeof atob === "function") return atob(b64)
|
||||
// Node fallback (SSR / tests)
|
||||
return Buffer.from(b64, "base64").toString("binary")
|
||||
}
|
||||
|
||||
export function decodeJwt(token: string): ArcadiaClaims | null {
|
||||
if (!token) return null
|
||||
const parts = token.split(".")
|
||||
if (parts.length !== 3) return null
|
||||
try {
|
||||
const raw = b64urlDecode(parts[1])
|
||||
// Handle UTF-8: atob returns binary string; reconstruct UTF-8.
|
||||
const utf8 =
|
||||
typeof TextDecoder !== "undefined"
|
||||
? new TextDecoder().decode(
|
||||
Uint8Array.from(raw, (c) => c.charCodeAt(0)),
|
||||
)
|
||||
: raw
|
||||
return JSON.parse(utf8) as ArcadiaClaims
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,14 @@
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
|
||||
import { profileInitials } from "~/lib/profile"
|
||||
import { decodeJwt, type AvailableTenantClaim } from "~/lib/jwt"
|
||||
|
||||
export type AvailableTenant = {
|
||||
id: string
|
||||
slug?: string
|
||||
name?: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
userId: string
|
||||
@@ -14,6 +22,11 @@ export type Session = {
|
||||
token: string
|
||||
// Issued at, ms since epoch.
|
||||
issuedAt: number
|
||||
// Active membership context — derived from the JWT.
|
||||
tenantId?: string
|
||||
tenantSlug?: string
|
||||
roles: string[]
|
||||
availableTenants: AvailableTenant[]
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "crema.session"
|
||||
@@ -41,6 +54,18 @@ function readFromStorage(): Session | null {
|
||||
token: parsed.token,
|
||||
issuedAt:
|
||||
typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(),
|
||||
tenantId: typeof parsed.tenantId === "string" ? parsed.tenantId : undefined,
|
||||
tenantSlug:
|
||||
typeof parsed.tenantSlug === "string" ? parsed.tenantSlug : undefined,
|
||||
roles: Array.isArray(parsed.roles)
|
||||
? parsed.roles.filter((r): r is string => typeof r === "string")
|
||||
: [],
|
||||
availableTenants: Array.isArray(parsed.availableTenants)
|
||||
? (parsed.availableTenants.filter(
|
||||
(t): t is AvailableTenant =>
|
||||
!!t && typeof (t as AvailableTenant).id === "string",
|
||||
) as AvailableTenant[])
|
||||
: [],
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -72,12 +97,31 @@ export function persistFromArcadiaLogin(
|
||||
[user?.first_name, user?.last_name].filter(Boolean).join(" ") ||
|
||||
user?.email ||
|
||||
"Signed-in user"
|
||||
const claims = decodeJwt(tokens.access_token) ?? {}
|
||||
const availableTenants: AvailableTenant[] = Array.isArray(
|
||||
claims.available_tenants,
|
||||
)
|
||||
? (claims.available_tenants as AvailableTenantClaim[])
|
||||
.filter((t) => t && typeof t.id === "string")
|
||||
.map((t) => ({
|
||||
id: t.id as string,
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
roles: Array.isArray(t.roles) ? t.roles : [],
|
||||
}))
|
||||
: []
|
||||
const session: Session = {
|
||||
userId: user?.id ?? `arcadia-${Date.now().toString(36)}`,
|
||||
name,
|
||||
email: user?.email ?? "",
|
||||
token: tokens.access_token,
|
||||
issuedAt: Date.now(),
|
||||
tenantId:
|
||||
typeof claims.tenant_id === "string" ? claims.tenant_id : undefined,
|
||||
tenantSlug:
|
||||
typeof claims.tenant_slug === "string" ? claims.tenant_slug : undefined,
|
||||
roles: Array.isArray(claims.roles) ? claims.roles : [],
|
||||
availableTenants,
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem("arcadia_access_token", tokens.access_token)
|
||||
|
||||
Reference in New Issue
Block a user