Add Storage, Users, Secrets, Webhooks, Scheduled tasks, Audit log screens

Full management surfaces for the platform-admin tenant, mirroring the
existing Tenants pattern (DataTable + row actions + create/edit dialogs +
ConfirmDialog for destructive ops, all data-action tagged for the
command bus, useRegisterAdminContext publishing for the assistant).

- Storage (/storage): backends + credentials. Write-only secret fields,
  Validate/Activate/Deactivate/Set-default/Mark-degraded/Maintenance.
- Users (/users): tabs for Users, Invitations, Roles. Per-user View
  drawer with profile, role add/remove, API keys (one-time reveal on
  create), usage + quota.
- Secrets (/secrets): /api/v1/admin/secrets — create/rotate/rollback,
  versions dialog, enable/disable, generate-value helper.
- Webhooks (/webhooks): CRUD, pause/resume, regenerate-secret with
  one-time reveal, send test event, deliveries dialog.
- Scheduled tasks (/scheduled-tasks): cron CRUD, run-now trigger,
  enable/disable, expandable run history.
- Audit log (/activity): replaces the empty stub. Filter by severity,
  resource type, date range; click for full JSON detail.

All endpoints are hand-rolled HTTP because most aren't covered by the
generated OpenAPI typed paths yet — switch to arcadia.typed.* when the
backend wires them into OpenApiSpex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-01 22:50:09 +10:00
parent 45fa130951
commit a907e25a7c
19 changed files with 7439 additions and 25 deletions

View File

@@ -0,0 +1,67 @@
// Arcadia per-user API key helpers (v2 multi-key path).
//
// `POST /api/v1/users/:user_id/api_keys` returns the raw key value exactly
// once — list/show endpoints only return the prefix. Callers must surface
// the value to the user immediately on create.
import type { ArcadiaClient } from "@crema/arcadia-client"
export interface ApiKey {
id: string
key_prefix: string
description: string | null
created_at: string
last_used_at: string | null
expires_at: string | null
revoked_at: string | null
is_active: boolean
}
export interface ApiKeyCreateInput {
description?: string
expires_at?: string | null
}
export interface ApiKeyCreated {
api_key: string
key_id: string
key_prefix: string
user_id: string
description: string | null
created_at: string
expires_at: string | null
warning: string
}
export async function listUserApiKeys(
arcadia: ArcadiaClient,
userId: string,
): Promise<ApiKey[]> {
const res = await arcadia.GET<{ data: ApiKey[] }>(
`/api/v1/users/${userId}/api_keys`,
)
return res.data
}
export async function createUserApiKey(
arcadia: ArcadiaClient,
userId: string,
input: ApiKeyCreateInput,
): Promise<ApiKeyCreated> {
const res = await arcadia.POST<{ data: ApiKeyCreated }>(
`/api/v1/users/${userId}/api_keys`,
{ body: input },
)
return res.data
}
export async function revokeUserApiKey(
arcadia: ArcadiaClient,
userId: string,
keyId: string,
reason?: string,
): Promise<void> {
await arcadia.DELETE(`/api/v1/users/${userId}/api_keys/${keyId}`, {
body: reason ? { reason } : undefined,
})
}

View File

@@ -0,0 +1,76 @@
// Audit log + observability helpers.
// All endpoints are read-only; the backend writes audit events itself.
import type { ArcadiaClient } from "@crema/arcadia-client"
export type AuditSeverity = "info" | "warning" | "error" | "critical" | string
export interface AuditUser {
id: string
email: string
name: string
}
export interface AuditLog {
id: string
tenant_id: string
user_id: string | null
user: AuditUser | null
action: string
resource_type: string
resource_id: string | null
changes: Record<string, unknown> | null
metadata: Record<string, unknown> | null
severity: AuditSeverity
ip_address: string | null
user_agent: string | null
inserted_at: string
}
export interface AuditListParams {
action?: string
resource_type?: string
severity?: AuditSeverity
user_id?: string
from?: string // ISO8601
to?: string
limit?: number
offset?: number
}
export interface AuditStats {
total: number
by_action: Record<string, number>
by_severity: Record<string, number>
by_resource_type: Record<string, number>
[key: string]: unknown
}
export async function listAuditLogs(
arcadia: ArcadiaClient,
params?: AuditListParams,
): Promise<AuditLog[]> {
const res = await arcadia.GET<{ data: AuditLog[] }>(
"/api/v1/observability/audit_logs",
{ params: params as Record<string, string | number | boolean | null | undefined> },
)
return res.data
}
export async function getAuditLog(arcadia: ArcadiaClient, id: string): Promise<AuditLog> {
const res = await arcadia.GET<{ data: AuditLog }>(
`/api/v1/observability/audit_logs/${id}`,
)
return res.data
}
export async function getAuditStats(
arcadia: ArcadiaClient,
params?: { from?: string; to?: string },
): Promise<AuditStats> {
const res = await arcadia.GET<{ data: AuditStats }>(
"/api/v1/observability/audit_stats",
{ params: params as Record<string, string | undefined> },
)
return res.data
}

View File

@@ -0,0 +1,65 @@
// Arcadia invitations API helpers.
// Backed by /api/v1/invitations.
import type { ArcadiaClient } from "@crema/arcadia-client"
export interface InvitationRole {
id: string
name: string
slug: string
}
export interface InvitationInviter {
id: string
email: string
name: string
}
export interface Invitation {
id: string
email: string
role: InvitationRole
invited_by: InvitationInviter | null
expires_at: string | null
accepted_at: string | null
revoked_at: string | null
revocation_reason: string | null
inserted_at: string
}
export type InvitationStatus = "pending" | "accepted" | "revoked" | "expired"
export function invitationStatus(inv: Invitation): InvitationStatus {
if (inv.accepted_at) return "accepted"
if (inv.revoked_at) return "revoked"
if (inv.expires_at && new Date(inv.expires_at).getTime() < Date.now()) return "expired"
return "pending"
}
export interface InvitationInput {
email: string
role_id: string
}
export async function listInvitations(arcadia: ArcadiaClient): Promise<Invitation[]> {
const res = await arcadia.GET<{ data: Invitation[] }>("/api/v1/invitations")
return res.data
}
export async function createInvitation(
arcadia: ArcadiaClient,
input: InvitationInput,
): Promise<Invitation> {
const res = await arcadia.POST<{ data: Invitation }>("/api/v1/invitations", {
body: { invitation: input },
})
return res.data
}
export async function revokeInvitation(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/invitations/${id}`)
}
export async function resendInvitation(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.POST(`/api/v1/invitations/${id}/resend`)
}

55
app/lib/arcadia/roles.ts Normal file
View File

@@ -0,0 +1,55 @@
// Arcadia roles API helpers.
// Backed by /api/v1/roles (resources route, except :new and :edit).
import type { ArcadiaClient } from "@crema/arcadia-client"
export interface Role {
id: string
name: string
slug: string
description: string | null
permissions: string[]
is_system: boolean
metadata: Record<string, unknown>
tenant_id: string
inserted_at: string
updated_at: string
}
export interface RoleInput {
name: string
slug: string
description?: string | null
permissions?: string[]
metadata?: Record<string, unknown>
}
export async function listRoles(arcadia: ArcadiaClient): Promise<Role[]> {
const res = await arcadia.GET<{ data: Role[] }>("/api/v1/roles")
return res.data
}
export async function getRole(arcadia: ArcadiaClient, id: string): Promise<Role> {
const res = await arcadia.GET<{ data: Role }>(`/api/v1/roles/${id}`)
return res.data
}
export async function createRole(arcadia: ArcadiaClient, input: RoleInput): Promise<Role> {
const res = await arcadia.POST<{ data: Role }>("/api/v1/roles", { body: { role: input } })
return res.data
}
export async function updateRole(
arcadia: ArcadiaClient,
id: string,
input: Partial<RoleInput>,
): Promise<Role> {
const res = await arcadia.PATCH<{ data: Role }>(`/api/v1/roles/${id}`, {
body: { role: input },
})
return res.data
}
export async function deleteRole(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/roles/${id}`)
}

View File

@@ -0,0 +1,130 @@
// Scheduled tasks (cron) helpers.
// Backend: /api/v1/admin/scheduled-tasks (CRUD + runs/enable/disable/trigger).
import type { ArcadiaClient } from "@crema/arcadia-client"
export type ScheduledTaskAction = "webhook" | "event"
export interface ScheduledTask {
id: string
tenant_id: string | null
name: string
description: string | null
cron_expression: string
timezone: string
action_type: ScheduledTaskAction
/** Backend-encrypted; rendered as null on read but accepted on writes. */
action_config?: Record<string, unknown> | null
tags: string[]
enabled: boolean
last_run_at: string | null
next_run_at: string | null
max_retries: number
timeout_seconds: number
inserted_at: string
updated_at: string
}
export interface ScheduledTaskInput {
name: string
description?: string | null
cron_expression: string
timezone?: string
action_type: ScheduledTaskAction
action_config: Record<string, unknown>
tags?: string[]
enabled?: boolean
max_retries?: number
timeout_seconds?: number
}
export interface TaskRun {
id: string
task_id: string
status: "pending" | "running" | "succeeded" | "failed" | string
attempt: number
started_at: string | null
finished_at: string | null
response_status: number | null
response_body: string | null
error: string | null
inserted_at: string
}
const BASE = "/api/v1/admin/scheduled-tasks"
export async function listScheduledTasks(arcadia: ArcadiaClient): Promise<ScheduledTask[]> {
const res = await arcadia.GET<{ data: ScheduledTask[] }>(BASE)
return res.data
}
export async function getScheduledTask(
arcadia: ArcadiaClient,
id: string,
): Promise<ScheduledTask> {
const res = await arcadia.GET<{ data: ScheduledTask }>(`${BASE}/${id}`)
return res.data
}
export async function createScheduledTask(
arcadia: ArcadiaClient,
input: ScheduledTaskInput,
): Promise<ScheduledTask> {
const res = await arcadia.POST<{ data: ScheduledTask }>(BASE, {
body: { scheduled_task: input },
})
return res.data
}
export async function updateScheduledTask(
arcadia: ArcadiaClient,
id: string,
input: Partial<ScheduledTaskInput>,
): Promise<ScheduledTask> {
const res = await arcadia.PATCH<{ data: ScheduledTask }>(`${BASE}/${id}`, {
body: { scheduled_task: input },
})
return res.data
}
export async function deleteScheduledTask(
arcadia: ArcadiaClient,
id: string,
): Promise<void> {
await arcadia.DELETE(`${BASE}/${id}`)
}
export async function enableScheduledTask(
arcadia: ArcadiaClient,
id: string,
): Promise<ScheduledTask> {
const res = await arcadia.POST<{ data: ScheduledTask }>(`${BASE}/${id}/enable`)
return res.data
}
export async function disableScheduledTask(
arcadia: ArcadiaClient,
id: string,
): Promise<ScheduledTask> {
const res = await arcadia.POST<{ data: ScheduledTask }>(`${BASE}/${id}/disable`)
return res.data
}
export async function triggerScheduledTask(
arcadia: ArcadiaClient,
id: string,
): Promise<TaskRun> {
const res = await arcadia.POST<{ data: TaskRun }>(`${BASE}/${id}/trigger`)
return res.data
}
export async function listTaskRuns(
arcadia: ArcadiaClient,
id: string,
params?: { limit?: number; offset?: number },
): Promise<TaskRun[]> {
const res = await arcadia.GET<{ data: TaskRun[] }>(`${BASE}/${id}/runs`, {
params: params as Record<string, number | undefined>,
})
return res.data
}

171
app/lib/arcadia/secrets.ts Normal file
View File

@@ -0,0 +1,171 @@
// Arcadia secrets API helpers.
//
// Backed by /api/v1/admin/secrets — the platform Secrets Manager. Values are
// AES-encrypted at rest and never returned by index/show; only metadata is
// exposed by these endpoints. Tenant-side resolution (returning the value)
// goes through a separate runtime endpoint that's not used by the admin UI.
import type { ArcadiaClient } from "@crema/arcadia-client"
export type SecretCategory =
| "api_key"
| "smtp"
| "oauth_token"
| "webhook_secret"
| "generic"
export type SecretEnvironment = "production" | "staging" | "development" | "all"
export interface Secret {
id: string
tenant_id: string | null
name: string
description: string | null
category: SecretCategory
environment: SecretEnvironment
tags: string[]
used_by: string[]
allowed_ips: string[]
read_once: boolean
read_once_consumed: boolean
expires_at: string | null
last_rotated_at: string | null
rotation_interval_days: number | null
rotation_due: boolean
expired: boolean
enabled: boolean
inserted_at: string
updated_at: string
}
export interface SecretVersion {
id: string
secret_id: string
version: number
note: string | null
inserted_by: string | null
inserted_at: string
}
export interface SecretCreateInput {
name: string
value: string
category?: SecretCategory
description?: string | null
environment?: SecretEnvironment
tags?: string[]
used_by?: string[]
allowed_ips?: string[]
read_once?: boolean
expires_at?: string | null
rotation_interval_days?: number | null
}
export type SecretMetaInput = Omit<Partial<SecretCreateInput>, "value" | "name">
export interface RotateInput {
value: string
note?: string
}
export async function listSecrets(arcadia: ArcadiaClient): Promise<Secret[]> {
const res = await arcadia.GET<{ data: Secret[] }>("/api/v1/admin/secrets")
return res.data
}
export async function getSecret(arcadia: ArcadiaClient, id: string): Promise<Secret> {
const res = await arcadia.GET<{ data: Secret }>(`/api/v1/admin/secrets/${id}`)
return res.data
}
export async function createSecret(
arcadia: ArcadiaClient,
input: SecretCreateInput,
): Promise<Secret> {
const res = await arcadia.POST<{ data: Secret }>("/api/v1/admin/secrets", {
body: { secret: input },
})
return res.data
}
export async function updateSecretMeta(
arcadia: ArcadiaClient,
id: string,
input: SecretMetaInput,
): Promise<Secret> {
const res = await arcadia.PATCH<{ data: Secret }>(`/api/v1/admin/secrets/${id}`, {
body: { secret: input },
})
return res.data
}
export async function deleteSecret(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/secrets/${id}`)
}
export async function rotateSecret(
arcadia: ArcadiaClient,
id: string,
input: RotateInput,
): Promise<Secret> {
const res = await arcadia.POST<{ data: Secret }>(`/api/v1/admin/secrets/${id}/rotate`, {
body: { secret: input },
})
return res.data
}
export async function rollbackSecret(
arcadia: ArcadiaClient,
id: string,
version: number,
): Promise<Secret> {
const res = await arcadia.POST<{ data: Secret }>(
`/api/v1/admin/secrets/${id}/rollback/${version}`,
)
return res.data
}
export async function enableSecret(arcadia: ArcadiaClient, id: string): Promise<Secret> {
const res = await arcadia.POST<{ data: Secret }>(`/api/v1/admin/secrets/${id}/enable`)
return res.data
}
export async function disableSecret(arcadia: ArcadiaClient, id: string): Promise<Secret> {
const res = await arcadia.POST<{ data: Secret }>(`/api/v1/admin/secrets/${id}/disable`)
return res.data
}
export async function listSecretVersions(
arcadia: ArcadiaClient,
id: string,
): Promise<SecretVersion[]> {
const res = await arcadia.GET<{ data: SecretVersion[] }>(
`/api/v1/admin/secrets/${id}/versions`,
)
return res.data
}
export async function generateSecretValue(
arcadia: ArcadiaClient,
params?: { length?: number; charset?: string },
): Promise<string> {
const res = await arcadia.GET<{ data: { value: string } }>("/api/v1/admin/secrets/generate", {
params: params as Record<string, string | number | boolean | null | undefined>,
})
return res.data.value
}
export const SECRET_CATEGORIES: { value: SecretCategory; label: string }[] = [
{ value: "api_key", label: "API key" },
{ value: "oauth_token", label: "OAuth token" },
{ value: "smtp", label: "SMTP credentials" },
{ value: "webhook_secret", label: "Webhook secret" },
{ value: "generic", label: "Generic" },
]
export const SECRET_ENVIRONMENTS: { value: SecretEnvironment; label: string }[] = [
{ value: "all", label: "All environments" },
{ value: "production", label: "Production" },
{ value: "staging", label: "Staging" },
{ value: "development", label: "Development" },
]

View File

@@ -0,0 +1,167 @@
// Arcadia storage configs API helpers.
//
// `GET /api/v1/storage_configs` and `POST /api/v1/storage_configs` are the
// only operations with full OpenAPI coverage today. Update/delete and the
// state-transition actions (activate, deactivate, mark-degraded,
// mark-maintenance, set-default, validate) are listed in the spec but their
// operations are still stubbed as `never`, so we hand-roll types and use the
// generic `arcadia.GET<T>` / `arcadia.POST<T>` / etc. — same pattern as
// `tenants.ts`. Switch to `arcadia.typed.*` when the spec gains coverage.
import type { ArcadiaClient } from "@crema/arcadia-client"
export type StorageBackend = "s3" | "local" | "gcs"
export type StorageStatus = "active" | "inactive" | "degraded" | "maintenance"
export interface StorageConfig {
id: string
tenant_id: string
name: string
backend_type: StorageBackend
status: StorageStatus
is_default: boolean
max_file_size_bytes: number | null
allowed_content_types: string[] | null
// Backend-specific fields. Secret fields are returned as "***" by the API.
config: Record<string, unknown>
inserted_at: string
updated_at: string
}
export interface StorageConfigInput {
name: string
backend_type: StorageBackend
config: Record<string, unknown>
is_default?: boolean
max_file_size_bytes?: number | null
allowed_content_types?: string[]
}
export interface StorageStats {
total_objects: number
total_size_bytes: number
by_backend: Record<string, unknown>
by_user: Record<string, unknown>
}
export interface StorageProvidersResponse {
data: Record<StorageBackend, { required_fields: string[]; optional_fields?: string[] }>
}
export async function listStorageConfigs(arcadia: ArcadiaClient): Promise<StorageConfig[]> {
const res = await arcadia.GET<{ data: StorageConfig[] }>("/api/v1/storage_configs")
return res.data
}
export async function getStorageConfig(arcadia: ArcadiaClient, id: string): Promise<StorageConfig> {
const res = await arcadia.GET<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}`)
return res.data
}
export async function createStorageConfig(
arcadia: ArcadiaClient,
input: StorageConfigInput,
): Promise<StorageConfig> {
const res = await arcadia.POST<{ data: StorageConfig }>("/api/v1/storage_configs", {
body: { storage_config: input },
})
return res.data
}
export async function updateStorageConfig(
arcadia: ArcadiaClient,
id: string,
input: Partial<StorageConfigInput>,
): Promise<StorageConfig> {
const res = await arcadia.PATCH<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}`, {
body: { storage_config: input },
})
return res.data
}
export async function deleteStorageConfig(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/storage_configs/${id}`)
}
export async function activateStorageConfig(arcadia: ArcadiaClient, id: string): Promise<StorageConfig> {
const res = await arcadia.POST<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}/activate`)
return res.data
}
export async function deactivateStorageConfig(arcadia: ArcadiaClient, id: string): Promise<StorageConfig> {
const res = await arcadia.POST<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}/deactivate`)
return res.data
}
export async function markStorageConfigDegraded(
arcadia: ArcadiaClient,
id: string,
): Promise<StorageConfig> {
const res = await arcadia.POST<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}/mark-degraded`)
return res.data
}
export async function markStorageConfigMaintenance(
arcadia: ArcadiaClient,
id: string,
): Promise<StorageConfig> {
const res = await arcadia.POST<{ data: StorageConfig }>(
`/api/v1/storage_configs/${id}/mark-maintenance`,
)
return res.data
}
export async function setDefaultStorageConfig(
arcadia: ArcadiaClient,
id: string,
): Promise<StorageConfig> {
const res = await arcadia.POST<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}/set-default`)
return res.data
}
export interface ValidateResult {
ok: boolean
message?: string
details?: unknown
}
export async function validateStorageConfig(
arcadia: ArcadiaClient,
id: string,
): Promise<ValidateResult> {
return arcadia.POST<ValidateResult>(`/api/v1/storage_configs/${id}/validate`)
}
export async function getStorageStats(arcadia: ArcadiaClient): Promise<StorageStats> {
const res = await arcadia.GET<{ data: StorageStats }>("/api/v1/storage_configs/stats")
return res.data
}
// Backend-specific config field schemas. Secret fields appear as "***" on
// reads — the form treats them as write-only and only sends a value when the
// user has typed a fresh one.
export const SECRET_FIELDS: Record<StorageBackend, readonly string[]> = {
s3: ["secret_access_key"],
gcs: ["service_account_json"],
local: [],
}
export const REQUIRED_FIELDS: Record<StorageBackend, readonly string[]> = {
s3: ["bucket", "region", "access_key_id", "secret_access_key"],
gcs: ["bucket", "service_account_json"],
local: ["path"],
}
export const OPTIONAL_FIELDS: Record<StorageBackend, readonly string[]> = {
s3: ["endpoint", "prefix"],
gcs: ["prefix"],
local: [],
}
export function isSecretField(backend: StorageBackend, field: string): boolean {
return SECRET_FIELDS[backend].includes(field)
}
export function isMaskedSecret(value: unknown): boolean {
return typeof value === "string" && value === "***"
}

View File

@@ -0,0 +1,56 @@
// Per-user usage + quota helpers.
import type { ArcadiaClient } from "@crema/arcadia-client"
export interface UserUsage {
storage_used_bytes: number
object_count: number
}
export interface UserQuota {
id: string
tenant_id: string
user_id: string
storage_limit_bytes: number | null
storage_used_bytes: number
object_count_limit: number | null
object_count: number
storage_remaining: number | null
objects_remaining: number | null
storage_usage_percentage: number | null
object_count_usage_percentage: number | null
storage_exceeded: boolean
object_count_exceeded: boolean
quota_exceeded: boolean
metadata: Record<string, unknown>
last_calculated_at: string | null
inserted_at: string
updated_at: string
}
export async function getUserUsage(
arcadia: ArcadiaClient,
userId: string,
): Promise<UserUsage> {
const res = await arcadia.GET<{ data: UserUsage }>(
`/api/v1/users/${userId}/usage`,
)
return res.data
}
export async function getUserQuota(
arcadia: ArcadiaClient,
userId: string,
): Promise<UserQuota | null> {
try {
const res = await arcadia.GET<{ data: UserQuota }>(
`/api/v1/users/${userId}/quota`,
)
return res.data
} catch (err) {
// 404 == no quota set for this user. Treat as null rather than throwing.
const msg = err instanceof Error ? err.message : String(err)
if (/404|not[_ ]found/i.test(msg)) return null
throw err
}
}

111
app/lib/arcadia/users.ts Normal file
View File

@@ -0,0 +1,111 @@
// Arcadia users API helpers.
//
// Backed by /api/v1/users (resources route). The OpenAPI spec doesn't yet
// describe these operations as typed paths, so we hand-roll types and use
// the generic verb methods on the client. Same pattern as tenants.ts.
import type { ArcadiaClient } from "@crema/arcadia-client"
export type UserStatus = "active" | "inactive" | "suspended"
export interface UserRoleSummary {
id: string
slug: string
name: string
permissions: string[]
}
export interface User {
id: string
email: string
first_name: string | null
last_name: string | null
full_name: string
status: UserStatus
email_verified: boolean
email_verified_at: string | null
last_sign_in_at: string | null
tenant_id: string
roles: UserRoleSummary[]
inserted_at: string
updated_at: string
}
export interface UserListParams {
status?: UserStatus
email_verified?: boolean
}
export interface UserInput {
email: string
first_name?: string | null
last_name?: string | null
status?: UserStatus
password?: string
role_ids?: string[]
}
export async function listUsers(
arcadia: ArcadiaClient,
params?: UserListParams,
): Promise<User[]> {
const queryParams = params
? {
status: params.status,
email_verified: params.email_verified == null ? undefined : String(params.email_verified),
}
: undefined
const res = await arcadia.GET<{ data: User[] }>("/api/v1/users", { params: queryParams })
return res.data
}
export async function getUser(arcadia: ArcadiaClient, id: string): Promise<User> {
const res = await arcadia.GET<{ data: User }>(`/api/v1/users/${id}`)
return res.data
}
export async function createUser(arcadia: ArcadiaClient, input: UserInput): Promise<User> {
const res = await arcadia.POST<{ data: User }>("/api/v1/users", { body: { user: input } })
return res.data
}
export async function updateUser(
arcadia: ArcadiaClient,
id: string,
input: Partial<UserInput>,
): Promise<User> {
const res = await arcadia.PATCH<{ data: User }>(`/api/v1/users/${id}`, {
body: { user: input },
})
return res.data
}
export async function deleteUser(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/users/${id}`)
}
export async function assignRole(
arcadia: ArcadiaClient,
userId: string,
roleId: string,
): Promise<User> {
const res = await arcadia.POST<{ data: User }>(`/api/v1/users/${userId}/roles/${roleId}`)
return res.data
}
export async function removeRole(
arcadia: ArcadiaClient,
userId: string,
roleId: string,
): Promise<User> {
const res = await arcadia.DELETE<{ data: User }>(`/api/v1/users/${userId}/roles/${roleId}`)
return res.data
}
export async function setUserStatus(
arcadia: ArcadiaClient,
id: string,
status: UserStatus,
): Promise<User> {
return updateUser(arcadia, id, { status })
}

161
app/lib/arcadia/webhooks.ts Normal file
View File

@@ -0,0 +1,161 @@
// Outbound webhook helpers.
// Backend: /api/v1/webhooks (CRUD + pause/resume/regenerate-secret/deliveries/stats/test).
import type { ArcadiaClient } from "@crema/arcadia-client"
export type WebhookStatus = "active" | "paused" | "disabled"
export type WebhookRetryStrategy = "linear" | "exponential"
export interface Webhook {
id: string
tenant_id: string
url: string
description: string | null
status: WebhookStatus
events: string[]
headers: Record<string, string>
max_retries: number
retry_strategy: WebhookRetryStrategy
last_triggered_at: string | null
success_count: number
failure_count: number
/** Only populated on create / regenerate-secret responses. */
secret?: string | null
inserted_at: string
updated_at: string
}
export interface WebhookInput {
url: string
description?: string | null
events?: string[]
headers?: Record<string, string>
max_retries?: number
retry_strategy?: WebhookRetryStrategy
}
export interface WebhookDelivery {
id: string
webhook_endpoint_id: string
event_type: string
status: "pending" | "delivered" | "failed" | string
attempt: number
request_url: string
request_headers: Record<string, string>
response_status: number | null
response_time_ms: number | null
error_message: string | null
sent_at: string | null
completed_at: string | null
next_retry_at: string | null
inserted_at: string
}
export interface WebhookStats {
success_rate: number
delivery_count: number
failure_count: number
avg_response_time_ms: number
[key: string]: unknown
}
export async function listWebhooks(arcadia: ArcadiaClient): Promise<Webhook[]> {
const res = await arcadia.GET<{ data: Webhook[] }>("/api/v1/webhooks")
return res.data
}
export async function getWebhook(arcadia: ArcadiaClient, id: string): Promise<Webhook> {
const res = await arcadia.GET<{ data: Webhook }>(`/api/v1/webhooks/${id}`)
return res.data
}
export async function createWebhook(
arcadia: ArcadiaClient,
input: WebhookInput,
): Promise<Webhook> {
const res = await arcadia.POST<{ data: Webhook }>("/api/v1/webhooks", {
body: { webhook_endpoint: input },
})
return res.data
}
export async function updateWebhook(
arcadia: ArcadiaClient,
id: string,
input: Partial<WebhookInput>,
): Promise<Webhook> {
const res = await arcadia.PATCH<{ data: Webhook }>(`/api/v1/webhooks/${id}`, {
body: { webhook_endpoint: input },
})
return res.data
}
export async function deleteWebhook(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/webhooks/${id}`)
}
export async function pauseWebhook(arcadia: ArcadiaClient, id: string): Promise<Webhook> {
const res = await arcadia.POST<{ data: Webhook }>(`/api/v1/webhooks/${id}/pause`)
return res.data
}
export async function resumeWebhook(arcadia: ArcadiaClient, id: string): Promise<Webhook> {
const res = await arcadia.POST<{ data: Webhook }>(`/api/v1/webhooks/${id}/resume`)
return res.data
}
export async function regenerateWebhookSecret(
arcadia: ArcadiaClient,
id: string,
): Promise<Webhook> {
const res = await arcadia.POST<{ data: Webhook }>(
`/api/v1/webhooks/${id}/regenerate-secret`,
)
return res.data
}
export async function listWebhookDeliveries(
arcadia: ArcadiaClient,
id: string,
params?: { limit?: number; offset?: number },
): Promise<WebhookDelivery[]> {
const res = await arcadia.GET<{ data: WebhookDelivery[] }>(
`/api/v1/webhooks/${id}/deliveries`,
{ params: params as Record<string, number | undefined> },
)
return res.data
}
export async function getWebhookStats(
arcadia: ArcadiaClient,
id: string,
): Promise<WebhookStats> {
const res = await arcadia.GET<{ data: WebhookStats }>(
`/api/v1/webhooks/${id}/stats`,
)
return res.data
}
export async function testWebhook(
arcadia: ArcadiaClient,
id: string,
): Promise<{ ok: boolean; message?: string; details?: unknown }> {
return arcadia.POST(`/api/v1/webhooks/${id}/test`)
}
// A starter list of platform events. Free-form by design — different deployments
// emit different events. Users can type custom values.
export const COMMON_WEBHOOK_EVENTS = [
"user.created",
"user.updated",
"user.deleted",
"tenant.created",
"tenant.updated",
"object.uploaded",
"object.deleted",
"secret.rotated",
"invitation.sent",
"invitation.accepted",
"scheduled_task.completed",
"scheduled_task.failed",
]