Files
zpan/src/lib/api.ts
T
2026-08-01 00:32:31 -04:00

1595 lines
49 KiB
TypeScript

import { type ApiKeyMetadata, ApiKeyTemplate } from '@shared/api-key-templates'
import type { OAuthProviderConfig } from '@shared/oauth-providers'
import type {
AllowedImageMime,
AnnouncementInput,
CloudCreditBalanceResponse,
CloudCreditLedgerResponse,
CompleteObjectUploadInput,
ConflictStrategy,
CreateBackgroundJobRequest,
CreateDownloadTaskInput,
CreateShareRequest,
CreateStorageInput,
DiscountQuote,
DownloaderHeartbeatInput,
DownloadTaskActionInput,
EmailSettings,
ImageDomainProviderResponse,
OAuthConsentContext,
OAuthConsentResult,
OAuthConsentSubmit,
OAuthGrant,
OAuthGrantList,
PatchStorageInput,
PresignObjectUploadPartsInput,
PublicProfile,
PublicUser,
RedeemGiftCardResponse,
ReplaceStorageInput,
ShareObjectItem,
ShareObjectsResponse,
ShareReadmeResponse,
SiteConfig,
SiteSettings,
UpdateDownloaderCreditBillingInput,
UpdateDownloaderInput,
UpdateDownloadTaskInput,
UpdateEmailSettingsInput,
UpdateImageDomainSettingsInput,
UpdateSiteCaptchaInput,
UpdateSiteIdentityInput,
UpdateSiteQuotasInput,
UpdateSiteRegistrationInput,
UpdateSiteWebDavInput,
UpdateStorageEgressBillingInput,
} from '@shared/schemas'
import type {
AdminAuditEvent,
AdminDashboardGrowthStats,
AdminDashboardOperationsStats,
AdminDashboardOverviewStats,
AdminDashboardSharingStats,
AdminDashboardStorageStats,
AdminDashboardTrafficStats,
AdminOverview,
Announcement,
AuditEvent,
AuthProvider,
AuthProviderList,
BackgroundJob,
BackgroundJobStatus,
BindingState,
BrandingConfig,
BrandingField,
BrandingThemeMode,
BrandingThemePresetId,
BrandingThemeValues,
ChangelogInfo,
CloudOrder,
CloudProduct,
CloudStoreTarget,
CursorPage,
Downloader,
DownloadTask,
DownloadTaskListItem,
DownloadTaskTimeline,
IhostConfigResponse,
ImageHosting,
InstanceInfo,
LicenseEntitlements,
Notification,
ObjectListItem,
ObjectUploadInstructions,
OrgQuota,
OrgQuotaEntitlement,
PaginatedResponse,
ShareListItem,
ShareView,
SiteInvitation,
Storage,
StorageObject,
StorageUsageCategory,
StorageUsageItem,
StorageUsageResponse,
StorageUsageSortDirection,
StorageUsageSortField,
} from '@shared/types'
import {
adminAuditApi,
adminDownloadersApi,
adminOverviewApi,
adminQuotas,
adminSiteInvitations,
adminTeams,
announcementsApi,
authedSharesApi,
authProviders,
backgroundJobsApi,
brandingAdminApi,
cloudStoreApi,
configzApi,
downloaderSelfApi,
downloadTasksApi,
emailConfig,
eventsUrlApi,
ihostApi,
ihostConfigApi,
imageDomainProviderApi,
inviteCodes,
licensingAdminApi,
licensingApi,
notificationsApi,
oauthGrantsApi,
objects,
publicSharesApi,
publicSiteInvitations,
siteSettingsApi,
siteStatsApi,
storages,
storageUsageApi,
system,
teamsApi,
trash,
userQuotas,
users,
} from './rpc'
export type { Storage, StorageObject }
export function getSiteConfig() {
return unwrap<SiteConfig>(configzApi.index.$get())
}
export function getStorageUsage() {
return unwrap<StorageUsageResponse>(storageUsageApi.index.$get())
}
export function listStorageUsageItems(
category: StorageUsageCategory,
page = 1,
pageSize = 20,
sortBy: StorageUsageSortField = 'size',
sortDir: StorageUsageSortDirection = 'desc',
) {
return unwrap<PaginatedResponse<StorageUsageItem>>(
storageUsageApi.items.$get({
query: { category, page: String(page), pageSize: String(pageSize), sortBy, sortDir },
}),
)
}
export function getSiteSettings() {
return unwrap<SiteSettings>(siteSettingsApi.index.$get())
}
export function getAdminOverview() {
return unwrap<AdminOverview>(adminOverviewApi.index.$get())
}
export function updateSiteIdentity(input: UpdateSiteIdentityInput) {
return unwrap<SiteSettings['identity']>(siteSettingsApi.identity.$put({ json: input }))
}
export function updateSiteRegistration(input: UpdateSiteRegistrationInput) {
return unwrap<SiteSettings['registration']>(siteSettingsApi.registration.$put({ json: input }))
}
export function updateSiteCaptcha(input: UpdateSiteCaptchaInput) {
return unwrap<SiteSettings['captcha']>(siteSettingsApi.captcha.$put({ json: input }))
}
export function updateSiteQuotas(input: UpdateSiteQuotasInput) {
return unwrap<SiteSettings['quotas']>(siteSettingsApi.quotas.$put({ json: input }))
}
export function updateSiteWebDav(input: UpdateSiteWebDavInput) {
return unwrap<SiteSettings['webdav']>(siteSettingsApi.webdav.$put({ json: input }))
}
export function verifySiteWebDav() {
return unwrap<SiteSettings['webdav']>(siteSettingsApi.webdav.verifications.$post())
}
export type UserQuota = Pick<
OrgQuota,
| 'orgId'
| 'baseQuota'
| 'entitlementQuota'
| 'quota'
| 'used'
| 'baseTrafficQuota'
| 'entitlementTrafficQuota'
| 'trafficQuota'
| 'trafficUsed'
| 'trafficPeriod'
| 'storagePlanName'
| 'storageExtraNames'
| 'trafficPlanName'
| 'trafficExtraNames'
| 'currentPlan'
>
export interface ErrorInfo {
reason: string
domain: string
metadata?: Record<string, string>
}
export interface ApiErrorBody {
error: {
code: number
message: string
status: string
details?: ErrorInfo[]
}
}
export class ApiError extends Error {
readonly status: number
readonly body: ApiErrorBody
readonly reason: string | undefined
readonly metadata: Record<string, string> | undefined
readonly canonicalStatus: string | undefined
constructor(status: number, body: ApiErrorBody) {
super(body.error.message)
this.name = 'ApiError'
this.status = status
this.body = body
this.reason = body.error.details?.[0]?.reason
this.metadata = body.error.details?.[0]?.metadata
this.canonicalStatus = body.error.status
}
}
// Normalizes any error payload into the AIP-193 `google.rpc.Status` body the
// server now returns. Real server errors pass through; network failures,
// non-JSON responses, and external (S3) fallbacks are wrapped synthetically.
function toErrorBody(status: number, raw: unknown): ApiErrorBody {
if (raw && typeof raw === 'object' && 'error' in raw) {
const error = (raw as { error: unknown }).error
if (error && typeof error === 'object') return raw as ApiErrorBody
return {
error: {
code: status,
message: typeof error === 'string' ? error : `HTTP ${status}`,
status: '',
details: [],
},
}
}
return {
error: { code: status, message: `HTTP ${status}`, status: '', details: [] },
}
}
const SESSION_REQUEST_TIMEOUT_MS = 10_000
export function isNameConflictError(
err: unknown,
): err is ApiError & { metadata: { conflictingName: string; conflictingId: string } } {
return err instanceof ApiError && err.status === 409 && err.reason === 'NAME_CONFLICT'
}
async function unwrap<T>(promise: Promise<Response>): Promise<T> {
const res = await promise
if (!res.ok) {
const parsed = await res.json().catch(() => ({}))
throw new ApiError(res.status, toErrorBody(res.status, parsed))
}
return res.json() as Promise<T>
}
// Counterpart to unwrap for 204 No Content responses: validate, return nothing.
async function discard(promise: Promise<Response>): Promise<void> {
const res = await promise
if (!res.ok) {
const parsed = await res.json().catch(() => ({}))
throw new ApiError(res.status, toErrorBody(res.status, parsed))
}
}
// Objects API — lists live objects only (the recycle bin is listTrash).
export function listObjectsByPath(
path: string,
pageToken: string | undefined = undefined,
pageSize = 100,
opts?: { type?: string; search?: string; orgId?: string },
) {
const query: Record<string, string> = { path, pageSize: String(pageSize) }
if (pageToken) query.pageToken = pageToken
if (opts?.type) query.type = opts.type
if (opts?.search) query.search = opts.search
if (opts?.orgId) query.orgId = opts.orgId
return unwrap<CursorPage<ObjectListItem>>(objects.index.$get({ query }))
}
export function getObject(id: string) {
return unwrap<StorageObject & { downloadUrl?: string }>(objects[':id'].$get({ param: { id } }))
}
// POST /api/objects returns a folder, or a file draft with `upload` instructions:
// the server-decided part size and one presigned PUT URL per slice.
export interface CreateObjectResult extends StorageObject {
upload?: ObjectUploadInstructions
}
export function createObject(data: {
name: string
type?: string
size?: number
parent: string
dirtype: number
onConflict?: ConflictStrategy
storageId?: string
}) {
return unwrap<CreateObjectResult>(objects.index.$post({ json: data }))
}
export function updateObject(id: string, data: { name?: string; parent?: string; onConflict?: ConflictStrategy }) {
return unwrap<StorageObject>(objects[':id'].$patch({ param: { id }, json: data }))
}
// Finalize an upload (draft → live): the client has PUT every slice and read each
// ETag. Returns the live object.
export function completeObjectUpload(id: string, uploadSessionId: string, parts: CompleteObjectUploadInput['parts']) {
return unwrap<StorageObject>(
objects[':id'].uploads[':uploadSessionId'].completions.$post({ param: { id, uploadSessionId }, json: { parts } }),
)
}
// Abort an in-progress upload and discard the draft.
export function abortObjectUpload(id: string, uploadSessionId: string, opts: { strictStorageCleanup?: boolean } = {}) {
return discard(
objects[':id'].uploads[':uploadSessionId'].$delete({
param: { id, uploadSessionId },
query: opts.strictStorageCleanup ? { strictStorageCleanup: '1' } : {},
}),
)
}
// Re-presign expired part URLs mid-upload (multipart only); the happy path uses
// the URLs from createObject.
export function presignObjectUploadParts(id: string, uploadSessionId: string, data: PresignObjectUploadPartsInput) {
return unwrap<{
uploadId: string | null
mode: 'multipart'
partSize: number
partCount: number
presignedExpiresAt: string
requiredHeaders: Record<string, string>
parts: ObjectUploadInstructions['parts']
}>(
objects[':id'].uploads[':uploadSessionId'].parts.$post({
param: { id, uploadSessionId },
json: data,
}),
)
}
// Soft delete: move a live object to trash (DELETE /api/objects/:id → 204).
export function deleteObject(id: string) {
return discard(objects[':id'].$delete({ param: { id } }))
}
export function copyObject(id: string, parent: string, onConflict?: ConflictStrategy) {
return unwrap<StorageObject>(objects[':id'].copies.$post({ param: { id }, json: { parent, onConflict } }))
}
export interface TransferObjectResult {
saved: StorageObject[]
skipped: Array<{ name: string; reason: string }>
sourceDeleted: boolean
}
export function transferObject(
id: string,
input: { targetOrgId: string; targetParent: string; mode: 'copy' | 'move' },
) {
return unwrap<TransferObjectResult>(objects[':id'].transfers.$post({ param: { id }, json: input }))
}
// ── Trash (the /objects grouping for trashed items) ──────────────────────────
// Lists trashed objects, roots only (a trashed folder is one entry).
export function listTrash(opts: { pageToken?: string; pageSize?: number } = {}) {
return unwrap<CursorPage<StorageObject>>(
trash.objects.$get({
query: {
pageSize: String(opts.pageSize ?? 20),
...(opts.pageToken ? { pageToken: opts.pageToken } : {}),
},
}),
)
}
export function getTrashObject(id: string) {
return unwrap<StorageObject>(trash.objects[':id'].$get({ param: { id } }))
}
export function restoreObject(id: string, onConflict?: ConflictStrategy) {
return unwrap<StorageObject>(trash.objects[':id'].restorations.$post({ param: { id }, json: { onConflict } }))
}
// Permanently delete one trashed root (recursive subtree purge, DELETE → 204).
export function purgeTrashObject(id: string) {
return discard(trash.objects[':id'].$delete({ param: { id } }))
}
// Remote Download API
export interface ListDownloadTasksOptions {
status?: string
category?: string
tag?: string
pageSize?: number
pageToken?: string
}
export function listDownloadTasks(opts: ListDownloadTasksOptions = {}) {
const query: Record<string, string> = {
pageSize: String(opts.pageSize ?? 50),
}
if (opts.status) query.status = opts.status
if (opts.category) query.category = opts.category
if (opts.tag) query.tag = opts.tag
if (opts.pageToken) query.pageToken = opts.pageToken
return unwrap<CursorPage<DownloadTaskListItem>>(downloadTasksApi.index.$get({ query }))
}
export function getDownloadTask(id: string) {
return unwrap<DownloadTask>(downloadTasksApi[':id'].$get({ param: { id } }))
}
export function createDownloadTask(data: CreateDownloadTaskInput) {
return unwrap<DownloadTask>(downloadTasksApi.index.$post({ json: data }))
}
export function updateDownloadTask(id: string, data: UpdateDownloadTaskInput) {
return unwrap<DownloadTask>(downloadTasksApi[':id'].$patch({ param: { id }, json: data }))
}
export function listDownloadTaskEvents(id: string) {
return unwrap<DownloadTaskTimeline>(downloadTasksApi[':id'].events.$get({ param: { id } }))
}
export function runDownloadTaskAction(id: string, action: DownloadTaskActionInput['action']) {
if (action === 'delete') {
return discard(downloadTasksApi[':id'].$delete({ param: { id } }))
}
if (action === 'retry' || action === 'restart') {
return unwrap<DownloadTask>(
downloadTasksApi[':id'].attempts.$post({ param: { id }, json: { fresh: action === 'restart' } }),
)
}
const status =
action === 'pause' ? ('paused' as const) : action === 'resume' ? ('queued' as const) : ('canceled' as const)
return unwrap<DownloadTask>(downloadTasksApi[':id'].status.$put({ param: { id }, json: { status } }))
}
// Unified server-sent events stream consumed by the authenticated application shell.
export function serverEventsUrl() {
return eventsUrlApi.index.$url()
}
export function listDownloaders() {
return unwrap<PaginatedResponse<Downloader>>(adminDownloadersApi.index.$get())
}
export function updateDownloader(id: string, data: UpdateDownloaderInput) {
return unwrap<Downloader>(adminDownloadersApi[':id'].$patch({ param: { id }, json: data }))
}
export function updateDownloaderCreditBilling(id: string, data: UpdateDownloaderCreditBillingInput) {
return unwrap<Downloader>(adminDownloadersApi[':id']['credit-billing'].$put({ param: { id }, json: data }))
}
export function deleteDownloader(id: string) {
return discard(adminDownloadersApi[':id'].$delete({ param: { id } }))
}
export function sendDownloaderHeartbeat(data: DownloaderHeartbeatInput) {
return unwrap<Downloader>(downloaderSelfApi.me.heartbeats.$post({ json: data }))
}
// Background Jobs API
export interface ListBackgroundJobsOptions {
status?: BackgroundJobStatus
type?: string
pageSize?: number
pageToken?: string
}
export function listBackgroundJobs(opts: ListBackgroundJobsOptions = {}) {
const query: Record<string, string> = {
pageSize: String(opts.pageSize ?? 20),
}
if (opts.status) query.status = opts.status
if (opts.type) query.type = opts.type
if (opts.pageToken) query.pageToken = opts.pageToken
return unwrap<CursorPage<BackgroundJob>>(backgroundJobsApi.index.$get({ query }))
}
export function getActiveBackgroundJobCount() {
return unwrap<{ activeCount: number }>(backgroundJobsApi.stats.$get())
}
export function createBackgroundJob(data: CreateBackgroundJobRequest) {
return unwrap<BackgroundJob>(backgroundJobsApi.index.$post({ json: data }))
}
export function getBackgroundJob(id: string) {
return unwrap<BackgroundJob>(backgroundJobsApi[':id'].$get({ param: { id } }))
}
export function cancelBackgroundJob(id: string) {
return unwrap<BackgroundJob>(
backgroundJobsApi[':id'].status.$put({ param: { id }, json: { status: 'canceled' as const } }),
)
}
export function retryBackgroundJob(id: string) {
return unwrap<BackgroundJob>(backgroundJobsApi[':id'].retries.$post({ param: { id } }))
}
// Admin dashboard stats
export interface AdminStatsRangeFilter {
from?: string
to?: string
timeZone?: 'UTC'
}
function statsRangeQuery(filter: AdminStatsRangeFilter = {}): Record<string, string> {
const query: Record<string, string> = {}
if (filter.from) query.from = filter.from
if (filter.to) query.to = filter.to
if (filter.timeZone) query.timeZone = filter.timeZone
return query
}
export function getAdminDashboardOverviewStats(filter: AdminStatsRangeFilter = {}) {
return unwrap<AdminDashboardOverviewStats>(siteStatsApi.overview.$get({ query: statsRangeQuery(filter) }))
}
export function getAdminDashboardOperationsStats(filter: AdminStatsRangeFilter = {}) {
return unwrap<AdminDashboardOperationsStats>(siteStatsApi.operations.$get({ query: statsRangeQuery(filter) }))
}
export function getAdminDashboardGrowthStats(filter: AdminStatsRangeFilter = {}) {
return unwrap<AdminDashboardGrowthStats>(siteStatsApi.growth.$get({ query: statsRangeQuery(filter) }))
}
export function getAdminDashboardStorageStats(filter: AdminStatsRangeFilter = {}) {
return unwrap<AdminDashboardStorageStats>(siteStatsApi.storage.$get({ query: statsRangeQuery(filter) }))
}
export function getAdminDashboardTrafficStats(filter: AdminStatsRangeFilter = {}) {
return unwrap<AdminDashboardTrafficStats>(siteStatsApi.traffic.$get({ query: statsRangeQuery(filter) }))
}
export function getAdminDashboardSharingStats(filter: AdminStatsRangeFilter = {}) {
return unwrap<AdminDashboardSharingStats>(siteStatsApi.sharing.$get({ query: statsRangeQuery(filter) }))
}
// Admin Storages API
export function listStorages() {
return unwrap<{ items: Storage[]; total: number }>(storages.index.$get())
}
export function createStorage(data: CreateStorageInput) {
return unwrap<Storage>(storages.index.$post({ json: data }))
}
export function getStorage(id: string) {
return unwrap<Storage>(storages[':id'].$get({ param: { id } }))
}
export function replaceStorage(id: string, data: ReplaceStorageInput) {
return unwrap<Storage>(storages[':id'].$put({ param: { id }, json: data }))
}
export function patchStorage(id: string, data: PatchStorageInput) {
return unwrap<Storage>(storages[':id'].$patch({ param: { id }, json: data }))
}
export function updateStorageEgressBilling(id: string, data: UpdateStorageEgressBillingInput) {
return unwrap<Storage>(storages[':id']['egress-billing'].$put({ param: { id }, json: data }))
}
export function deleteStorage(id: string) {
return discard(storages[':id'].$delete({ param: { id } }))
}
// User entitlements API (admin). User identity, listing, disable/enable and
// delete are served directly by better-auth's /api/auth/admin/* endpoints (see
// the admin client in auth-client.ts). Only the personal-org storage
// entitlements stay here, in our own quota domain, keyed by user id.
export type UserEntitlementsResponse = { orgId: string; items: OrgQuotaEntitlement[] }
export function listUserEntitlements(userId: string) {
return unwrap<UserEntitlementsResponse>(users[':userId'].entitlements.$get({ param: { userId } }))
}
export function grantUserEntitlement(
userId: string,
data: { resourceType: 'storage'; bytes: number; expiresAt?: string | null; note?: string | null },
) {
return unwrap<{ orgId: string; entitlement: OrgQuotaEntitlement }>(
users[':userId'].entitlements.$post({ param: { userId }, json: data }),
)
}
export function updateUserEntitlement(
userId: string,
entitlementId: string,
data: { bytes?: number; expiresAt?: string | null; note?: string | null },
) {
return unwrap<{ orgId: string; entitlement: OrgQuotaEntitlement }>(
users[':userId'].entitlements[':eid'].$patch({ param: { userId, eid: entitlementId }, json: data }),
)
}
export function revokeUserEntitlement(userId: string, entitlementId: string) {
return discard(users[':userId'].entitlements[':eid'].$delete({ param: { userId, eid: entitlementId } }))
}
// Admin Quotas API
export type QuotaItem = Pick<
OrgQuota,
| 'orgId'
| 'baseQuota'
| 'entitlementQuota'
| 'quota'
| 'used'
| 'baseTrafficQuota'
| 'entitlementTrafficQuota'
| 'trafficQuota'
| 'trafficUsed'
| 'trafficPeriod'
> & {
orgName?: string
orgType?: string
}
export function listQuotas() {
return unwrap<{ items: QuotaItem[]; total: number }>(adminQuotas.index.$get())
}
export type AdminUserQuota = { used: number; total: number; hasPersonalOrg: boolean }
// A single user's storage used/total — a user sub-resource the admin UI fans out
// over (Promise.all) to enrich better-auth's admin list, which knows identity but
// not quota. (Distinct from getUserQuota(), which returns the caller's own quota.)
export function getUserQuotaById(userId: string) {
return unwrap<AdminUserQuota>(users[':userId'].quota.$get({ param: { userId } }))
}
// Admin Teams API
export interface TeamSummary {
id: string
name: string
slug: string
logo: string | null
memberCount: number
ownerName: string | null
quotaUsed: number
quotaTotal: number
createdAt: number
}
export function listTeams() {
return unwrap<{ items: TeamSummary[]; total: number }>(adminTeams.index.$get())
}
export function getTeam(orgId: string) {
return unwrap<TeamSummary>(adminTeams[':teamId'].$get({ param: { teamId: orgId } }))
}
export function listOrgEntitlements(orgId: string) {
return unwrap<{ orgId: string; items: OrgQuotaEntitlement[] }>(
adminTeams[':teamId'].entitlements.$get({ param: { teamId: orgId } }),
)
}
export function grantOrgEntitlement(
orgId: string,
data: { resourceType: 'storage'; bytes: number; expiresAt?: string | null; note?: string | null },
) {
return unwrap<{ orgId: string; entitlement: OrgQuotaEntitlement }>(
adminTeams[':teamId'].entitlements.$post({ param: { teamId: orgId }, json: data }),
)
}
export function updateOrgEntitlement(
orgId: string,
entitlementId: string,
data: { bytes?: number; expiresAt?: string | null; note?: string | null },
) {
return unwrap<{ orgId: string; entitlement: OrgQuotaEntitlement }>(
adminTeams[':teamId'].entitlements[':eid'].$patch({ param: { teamId: orgId, eid: entitlementId }, json: data }),
)
}
export function revokeOrgEntitlement(orgId: string, entitlementId: string) {
return discard(adminTeams[':teamId'].entitlements[':eid'].$delete({ param: { teamId: orgId, eid: entitlementId } }))
}
// User Quotas API
export function getUserQuota() {
return unwrap<UserQuota>(userQuotas.me.$get())
}
// Quota Store API
export function listCloudProducts() {
return unwrap<{ items: CloudProduct[]; total: number }>(cloudStoreApi.packages.$get())
}
export function listCloudCreditProducts() {
return unwrap<{ items: CloudProduct[]; total: number }>(cloudStoreApi.credits.products.$get())
}
export function listCloudStoreTargets() {
return unwrap<{ items: CloudStoreTarget[]; total: number }>(cloudStoreApi.targets.$get())
}
export function getCloudCredits() {
return unwrap<CloudCreditBalanceResponse>(cloudStoreApi.credits.$get())
}
export function listCloudCreditLedgerEntries() {
return unwrap<CloudCreditLedgerResponse>(cloudStoreApi.credits['ledger-entries'].$get())
}
export function redeemCloudGiftCard(code: string) {
return unwrap<RedeemGiftCardResponse>(cloudStoreApi.credits.redemptions.$post({ json: { code } }))
}
export function createCloudCheckout(packageId: string, priceId?: string, promotionCode?: string) {
return unwrap<{ orderId: string; url: string; paymentId?: string }>(
cloudStoreApi.checkouts.$post({ json: { packageId, priceId, promotionCode } }),
)
}
export function createDiscountQuote(code: string, priceId: string) {
return unwrap<DiscountQuote>(cloudStoreApi['discount-quotes'].$post({ json: { code, priceId } }))
}
export function createCloudBillingPortalSession() {
return unwrap<{ url: string; stripeSubscriptionId: string }>(cloudStoreApi['billing-portal-sessions'].$post())
}
export function continueCloudOrderPayment(orderId: string) {
return unwrap<{ orderId: string; url: string; paymentId?: string }>(
cloudStoreApi.orders[':orderId'].payments.$post({ param: { orderId } }),
)
}
export function cancelCloudOrder(orderId: string) {
return unwrap<CloudOrder>(
cloudStoreApi.orders[':orderId'].status.$put({ param: { orderId }, json: { status: 'canceled' } }),
)
}
export function listCloudOrders(options: { limit?: number; offset?: number } = {}) {
const query = {
...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
...(options.offset !== undefined ? { offset: String(options.offset) } : {}),
}
return unwrap<{ items: CloudOrder[]; total: number }>(cloudStoreApi.orders.$get({ query }))
}
// Auth Providers API
export type { AuthProvider }
export function listAuthProviders() {
return unwrap<AuthProviderList>(authProviders.index.$get())
}
export function upsertAuthProvider(providerId: string, data: Omit<OAuthProviderConfig, 'providerId'>) {
return unwrap<AuthProvider>(authProviders[':providerId'].$put({ param: { providerId }, json: data }))
}
export function deleteAuthProvider(providerId: string) {
return discard(authProviders[':providerId'].$delete({ param: { providerId } }))
}
// Invite Codes API
export interface InviteCode {
id: string
code: string
createdBy: string
usedBy: string | null
usedAt: string | null
expiresAt: string | null
createdAt: string
}
export function listInviteCodes(page = 1, pageSize = 20) {
return unwrap<{ items: InviteCode[]; total: number }>(
inviteCodes.index.$get({ query: { page: String(page), pageSize: String(pageSize) } }),
)
}
export function generateInviteCodes(count: number, expiresInDays?: number) {
const body: { count: number; expiresInDays?: number } = { count }
if (expiresInDays !== undefined) body.expiresInDays = expiresInDays
return unwrap<{ codes: InviteCode[] }>(inviteCodes.index.$post({ json: body }))
}
export function deleteInviteCode(id: string) {
return discard(inviteCodes[':id'].$delete({ param: { id } }))
}
// Site Invitations API
export function listSiteInvitations(page = 1, pageSize = 20) {
return unwrap<{ items: SiteInvitation[]; total: number }>(
adminSiteInvitations.index.$get({ query: { page: String(page), pageSize: String(pageSize) } }),
)
}
export function createSiteInvitation(email: string) {
return unwrap<SiteInvitation>(adminSiteInvitations.index.$post({ json: { email } }))
}
export function resendSiteInvitation(id: string) {
return unwrap<SiteInvitation>(adminSiteInvitations[':id'].deliveries.$post({ param: { id } }))
}
export function revokeSiteInvitation(id: string) {
return discard(adminSiteInvitations[':id'].$delete({ param: { id } }))
}
export function getSiteInvitation(token: string) {
return unwrap<SiteInvitation>(publicSiteInvitations[':token'].$get({ param: { token } }))
}
// Email settings API
export type EmailConfigData = UpdateEmailSettingsInput
export function getEmailConfig() {
return unwrap<EmailSettings>(emailConfig.index.$get())
}
export function saveEmailConfig(data: EmailConfigData) {
return unwrap<{ success: boolean }>(emailConfig.index.$put({ json: data }))
}
export function testEmail(to: string) {
return unwrap<{ success: boolean; error?: string }>(emailConfig['test-messages'].$post({ json: { to } }))
}
// Image custom-domain provider API
export type ImageDomainProviderData = UpdateImageDomainSettingsInput
export function getImageDomainProvider() {
return unwrap<ImageDomainProviderResponse>(imageDomainProviderApi.index.$get())
}
export function saveImageDomainProvider(data: ImageDomainProviderData) {
return unwrap<{ success: true }>(imageDomainProviderApi.index.$put({ json: data }))
}
export function testImageDomainProvider() {
return unwrap<{ success: true }>(imageDomainProviderApi.tests.$post())
}
// Profile API (public, no auth)
export type { PublicUser }
export function getProfile(username: string) {
return unwrap<PublicProfile>(users[':username'].$get({ param: { username } }))
}
// Teams Activity API
export function listTeamActivities(teamId: string, page = 1, pageSize = 20) {
return unwrap<PaginatedResponse<AuditEvent>>(
teamsApi[':teamId'].activity.$get({ param: { teamId }, query: { page: String(page), pageSize: String(pageSize) } }),
)
}
// Notifications API
export type NotificationListResult = {
items: Notification[]
nextPageToken: string | null
}
export function listNotifications(opts: { pageToken?: string; pageSize?: number; unreadOnly?: boolean } = {}) {
return unwrap<NotificationListResult>(
notificationsApi.index.$get({
query: {
pageSize: String(opts.pageSize ?? 20),
unread: String(opts.unreadOnly ?? false),
...(opts.pageToken ? { pageToken: opts.pageToken } : {}),
},
}),
)
}
export function getUnreadCount() {
return unwrap<{ count: number }>(notificationsApi.stats.$get())
}
export function markNotificationRead(id: string) {
return notificationsApi[':id'].$patch({ param: { id } }).then((res) => {
if (!res.ok) throw new ApiError(res.status, toErrorBody(res.status, { error: res.statusText }))
})
}
export function markAllNotificationsRead() {
return unwrap<{ count: number }>(notificationsApi.index.$patch())
}
// Announcements API
export type { Announcement, AnnouncementInput }
export type AnnouncementListResult = {
items: Announcement[]
total: number
page: number
pageSize: number
}
export function listAnnouncements(page = 1, pageSize = 20) {
return unwrap<AnnouncementListResult>(
announcementsApi.index.$get({ query: { page: String(page), pageSize: String(pageSize) } }),
)
}
export function listActiveAnnouncements() {
return unwrap<AnnouncementListResult>(
announcementsApi.index.$get({ query: { scope: 'active', page: '1', pageSize: '20' } }),
)
}
export function listAdminAnnouncements(page = 1, pageSize = 20, status?: Announcement['status']) {
const query: { scope: 'all'; page: string; pageSize: string; status?: Announcement['status'] } = {
scope: 'all',
page: String(page),
pageSize: String(pageSize),
}
if (status) query.status = status
return unwrap<AnnouncementListResult>(announcementsApi.index.$get({ query }))
}
export function createAnnouncement(data: AnnouncementInput) {
return unwrap<Announcement>(announcementsApi.index.$post({ json: data }))
}
export function getAnnouncement(id: string) {
return unwrap<Announcement>(announcementsApi[':id'].$get({ param: { id } }))
}
export function updateAnnouncement(id: string, data: AnnouncementInput) {
return unwrap<Announcement>(announcementsApi[':id'].$put({ param: { id }, json: data }))
}
export function deleteAnnouncement(id: string) {
return discard(announcementsApi[':id'].$delete({ param: { id } }))
}
// Shares API
export type { ShareListItem, ShareView }
export function listShares(pageToken?: string, pageSize = 20, status?: 'active' | 'revoked') {
const query: Record<string, string> = { pageSize: String(pageSize) }
if (pageToken) query.pageToken = pageToken
if (status) query.status = status
return unwrap<CursorPage<ShareListItem>>(authedSharesApi.index.$get({ query }))
}
export function listReceivedShares(pageToken?: string, pageSize = 20) {
const query: Record<string, string> = { pageSize: String(pageSize), box: 'received' }
if (pageToken) query.pageToken = pageToken
return unwrap<CursorPage<ShareListItem>>(authedSharesApi.index.$get({ query }))
}
export function getShare(token: string) {
return unwrap<ShareView>(publicSharesApi[':token'].$get({ param: { token } }))
}
export function revokeShare(token: string) {
return unwrap<ShareView>(authedSharesApi[':token'].status.$put({ param: { token }, json: { status: 'revoked' } }))
}
export function setSharePrivacy(token: string, isPrivate: boolean) {
return unwrap<{ private: boolean }>(
authedSharesApi[':token'].privacy.$put({ param: { token }, json: { private: isPrivate } }),
)
}
export interface CreateShareResult {
token: string
kind: ShareView['kind']
urls: { landing?: string; direct?: string }
expiresAt: string | null
downloadLimit: number | null
private: boolean
}
export function createShare(data: CreateShareRequest) {
return unwrap<CreateShareResult>(authedSharesApi.index.$post({ json: data }))
}
export function verifySharePassword(token: string, password: string) {
return unwrap<{ ok: boolean }>(publicSharesApi[':token'].sessions.$post({ param: { token }, json: { password } }))
}
export type ShareChildItem = ShareObjectItem
export type ShareChildrenResponse = ShareObjectsResponse
export function listShareObjects(token: string, parent = '', pageToken?: string, pageSize = 50) {
return unwrap<ShareChildrenResponse>(
publicSharesApi[':token'].objects.$get({
param: { token },
query: { parent, pageSize: String(pageSize), ...(pageToken ? { pageToken } : {}) },
}),
)
}
export function getShareReadme(token: string) {
return unwrap<ShareReadmeResponse>(publicSharesApi[':token'].readme.$get({ param: { token } }))
}
export function buildShareObjectUrl(token: string, ref: string): string {
return `/api/shares/${token}/objects/${ref}`
}
export interface SaveShareInput {
targetOrgId: string
targetParent: string
}
export interface SaveShareResult {
saved: Array<{ id: string; name: string }>
skipped: Array<{ name: string; reason: string }>
}
export function saveShareToDrive(token: string, data: SaveShareInput) {
return unwrap<SaveShareResult>(authedSharesApi[':token'].objects.$post({ param: { token }, json: data }))
}
// Image Host Config API
export type { IhostConfigResponse }
export function getIhostConfig() {
return unwrap<IhostConfigResponse>(ihostConfigApi.index.$get())
}
export function enableIhostFeature() {
return unwrap<IhostConfigResponse>(ihostConfigApi.index.$put({ json: { enabled: true } }))
}
export function updateIhostConfig(data: { customDomain?: string | null; refererAllowlist?: string[] | null }) {
return unwrap<IhostConfigResponse>(ihostConfigApi.index.$put({ json: { enabled: true, ...data } }))
}
export function deleteIhostConfig() {
return ihostConfigApi.index.$delete().then((res) => {
if (!res.ok) throw new ApiError(res.status, toErrorBody(res.status, { error: res.statusText }))
})
}
export type { OAuthConsentContext, OAuthConsentResult, OAuthGrant, OAuthGrantList }
export function getOAuthConsentContext(oauthQuery: string) {
return unwrap<OAuthConsentContext>(oauthGrantsApi['oauth-consent'].$get({ query: { oauthQuery } }))
}
export function submitOAuthConsent(input: OAuthConsentSubmit) {
return unwrap<OAuthConsentResult>(oauthGrantsApi['oauth-consent'].$post({ json: input }))
}
export function listOAuthGrants() {
return unwrap<OAuthGrantList>(oauthGrantsApi['oauth-grants'].$get())
}
export function revokeOAuthGrant(grantId: string) {
return discard(oauthGrantsApi['oauth-grants'][':grantId'].$delete({ param: { grantId } }))
}
// Image Host API Keys (via better-auth apiKey plugin)
export interface IhostApiKey {
id: string
configId: string
name: string | null
start: string | null
prefix: string | null
createdAt: string
lastRequest: string | null
permissions: Record<string, string[]> | null
metadata: ApiKeyMetadata | null
referenceId: string
enabled: boolean
}
export interface CreateIhostApiKeyResult extends IhostApiKey {
key: string
}
async function apiKeyFetch<T>(path: string, options: RequestInit): Promise<T> {
const res = await fetch(`/api/auth${path}`, { credentials: 'include', ...options })
if (!res.ok) {
const parsed = await res.json().catch(() => ({}))
throw new ApiError(res.status, toErrorBody(res.status, parsed))
}
return res.json() as Promise<T>
}
export function listApiKeys() {
return apiKeyFetch<{ apiKeys: IhostApiKey[] }>('/api-key/list', {
method: 'GET',
}).then((res) => res.apiKeys)
}
export function createIhostApiKey(organizationId: string, name: string) {
return apiKeyFetch<CreateIhostApiKeyResult>('/api-key/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
configId: ApiKeyTemplate.IHOST,
name,
organizationId,
}),
})
}
export function revokeIhostApiKey(keyId: string) {
return apiKeyFetch<{ success: boolean }>('/api-key/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ configId: ApiKeyTemplate.IHOST, keyId }),
})
}
// WebDAV App Passwords (via better-auth apiKey plugin)
export type WebDavAppPassword = IhostApiKey
export interface CreateWebDavAppPasswordResult extends WebDavAppPassword {
key: string
}
export function createWebDavAppPassword(name: string) {
return apiKeyFetch<CreateWebDavAppPasswordResult>('/api-key/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
configId: 'webdav',
name,
}),
})
}
export function revokeWebDavAppPassword(keyId: string) {
return apiKeyFetch<{ success: boolean }>('/api-key/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ configId: 'webdav', keyId }),
})
}
// Remote Download API Keys (via better-auth apiKey plugin)
export type RemoteDownloadApiKey = IhostApiKey
export interface CreateRemoteDownloadApiKeyResult extends RemoteDownloadApiKey {
key: string
}
export function createRemoteDownloadApiKey(organizationId: string, name: string) {
return apiKeyFetch<CreateRemoteDownloadApiKeyResult>('/api-key/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
configId: ApiKeyTemplate.REMOTE_DOWNLOAD,
name,
organizationId,
}),
})
}
export function revokeRemoteDownloadApiKey(keyId: string) {
return apiKeyFetch<{ success: boolean }>('/api-key/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ configId: ApiKeyTemplate.REMOTE_DOWNLOAD, keyId }),
})
}
// Licensing API
export type { BindingState }
export interface PairingInfo {
code: string
pairingUrl: string
expiresAt: string
}
export interface PairingPollResult {
status: 'pending' | 'approved' | 'denied' | 'expired'
plan?: string
}
export function getLicensingStatus() {
return unwrap<BindingState>(licensingAdminApi.binding.$get())
}
export function getLicenseEntitlements() {
return unwrap<LicenseEntitlements>(licensingApi.entitlements.$get())
}
export function getInstanceInfo() {
return unwrap<InstanceInfo>(system.instance.$get())
}
export function getChangelog(opts?: { refresh?: boolean }) {
return unwrap<ChangelogInfo>(system.changelog.$get({ query: opts?.refresh ? { refresh: 'true' } : {} }))
}
export function connectCloud() {
return unwrap<PairingInfo>(licensingAdminApi.pairings.$post())
}
export function pollPairing(code: string) {
return unwrap<PairingPollResult>(licensingAdminApi.pairings[':code'].$get({ param: { code } }))
}
export function refreshLicense() {
return unwrap<{ success: boolean; last_refresh_at: number | null }>(licensingAdminApi['refresh-runs'].$post())
}
export function disconnectCloud() {
return discard(licensingAdminApi.binding.$delete())
}
type SessionData = { session: unknown; user: unknown } | null
const SESSION_CACHE_TTL_MS = 5000 // 5 seconds
let sessionCache: { value: SessionData; at: number } | null = null
let sessionInflight: Promise<SessionData> | null = null
export function clearSessionCache() {
sessionCache = null
sessionInflight = null
}
async function fetchSession(): Promise<SessionData> {
const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), SESSION_REQUEST_TIMEOUT_MS)
try {
const res = await fetch('/api/auth/get-session', { credentials: 'include', signal: controller.signal })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new ApiError(res.status, toErrorBody(res.status, body))
}
return res.json()
} catch (error) {
if (controller.signal.aborted) throw new Error('Session request timed out')
throw error
} finally {
window.clearTimeout(timeout)
}
}
// Auth API — Better Auth passthrough, not typed via Hono RPC.
// Concurrent callers share one in-flight request no matter how long it takes;
// a resolved value is served for SESSION_CACHE_TTL_MS; failures are not cached.
export function getSession(): Promise<SessionData> {
if (sessionCache && Date.now() - sessionCache.at < SESSION_CACHE_TTL_MS) {
return Promise.resolve(sessionCache.value)
}
if (sessionInflight) return sessionInflight
const request = fetchSession()
.then((value) => {
sessionCache = { value, at: Date.now() }
return value
})
.finally(() => {
if (sessionInflight === request) sessionInflight = null
})
sessionInflight = request
return request
}
export interface UploadProgress {
loaded: number
total: number
}
export interface UploadToS3Options {
onProgress?: (progress: UploadProgress) => void
signal?: AbortSignal
contentDisposition?: string
}
// S3 direct upload (external presigned URL, not our API)
export function uploadToS3(url: string, file: File, options: UploadToS3Options = {}): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const abort = () => {
xhr.abort()
reject(new DOMException('Upload cancelled', 'AbortError'))
}
if (options.signal?.aborted) {
reject(new DOMException('Upload cancelled', 'AbortError'))
return
}
options.signal?.addEventListener('abort', abort, { once: true })
xhr.upload.onprogress = (event) => {
options.onProgress?.({
loaded: event.loaded,
total: event.lengthComputable ? event.total : file.size,
})
}
xhr.onload = () => {
options.signal?.removeEventListener('abort', abort)
if (xhr.status >= 200 && xhr.status < 300) {
options.onProgress?.({ loaded: file.size, total: file.size })
resolve()
return
}
reject(new Error('Upload failed'))
}
xhr.onerror = () => {
options.signal?.removeEventListener('abort', abort)
reject(new Error('Upload failed'))
}
xhr.onabort = () => {
options.signal?.removeEventListener('abort', abort)
}
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
if (options.contentDisposition) {
xhr.setRequestHeader('Content-Disposition', options.contentDisposition)
}
xhr.send(file)
})
}
export interface UploadPartOptions {
onProgress?: (progress: UploadProgress) => void
signal?: AbortSignal
contentType?: string
}
/**
* PUTs a single multipart part (external presigned URL) and resolves with its
* ETag, which the multipart-complete call needs. The S3 bucket's CORS config
* must expose the ETag response header for this to be readable from the browser.
*/
export function uploadPartToS3(url: string, blob: Blob, options: UploadPartOptions = {}): Promise<string> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const abort = () => {
xhr.abort()
reject(new DOMException('Upload cancelled', 'AbortError'))
}
if (options.signal?.aborted) {
reject(new DOMException('Upload cancelled', 'AbortError'))
return
}
options.signal?.addEventListener('abort', abort, { once: true })
xhr.upload.onprogress = (event) => {
options.onProgress?.({ loaded: event.loaded, total: event.lengthComputable ? event.total : blob.size })
}
xhr.onload = () => {
options.signal?.removeEventListener('abort', abort)
if (xhr.status >= 200 && xhr.status < 300) {
const etag = xhr.getResponseHeader('ETag')
if (!etag) {
reject(new Error('Missing ETag — the storage bucket must expose the ETag header via CORS'))
return
}
options.onProgress?.({ loaded: blob.size, total: blob.size })
resolve(etag.replace(/"/g, ''))
return
}
reject(new Error('Upload failed'))
}
xhr.onerror = () => {
options.signal?.removeEventListener('abort', abort)
reject(new Error('Upload failed'))
}
xhr.onabort = () => {
options.signal?.removeEventListener('abort', abort)
}
xhr.open('PUT', url)
if (options.contentType) xhr.setRequestHeader('Content-Type', options.contentType)
xhr.send(blob)
})
}
// Image Host Images API
export type { ImageHosting }
export interface IhostImageListResult {
items: ImageHosting[]
nextPageToken: string | null
}
export interface IhostImageDraft {
id: string
token: string
path: string
uploadUrl: string
storageKey: string
}
export function listIhostImages(opts?: { pathPrefix?: string; pageToken?: string; pageSize?: number }) {
const query: Record<string, string> = {}
if (opts?.pathPrefix) query.pathPrefix = opts.pathPrefix
if (opts?.pageToken) query.pageToken = opts.pageToken
if (opts?.pageSize != null) query.pageSize = String(opts.pageSize)
return unwrap<IhostImageListResult>(ihostApi.images.$get({ query }))
}
export function createIhostImagePresign(data: { path: string; mime: AllowedImageMime; size: number }) {
return unwrap<IhostImageDraft>(ihostApi.images.presign.$post({ json: data }))
}
export function confirmIhostImage(id: string) {
return unwrap<ImageHosting>(ihostApi.images[':id'].status.$put({ param: { id } }))
}
export async function deleteIhostImage(id: string) {
const res = await ihostApi.images[':id'].$delete({ param: { id } })
if (!res.ok) {
const parsed = await res.json().catch(() => ({}))
throw new ApiError(res.status, toErrorBody(res.status, parsed))
}
}
// Public image upload (avatar / org logo)
//
// PUT endpoints use multipart/form-data, which Hono RPC doesn't express
// cleanly — we use raw fetch for PUT and keep Hono RPC for DELETE. Returns
// the permanent public URL that was just written to user.image /
// organization.logo.
async function putImageMultipart(url: string, file: File): Promise<{ url: string }> {
const form = new FormData()
form.set('file', file)
const res = await fetch(url, {
method: 'PUT',
body: form,
credentials: 'include',
})
if (!res.ok) {
const parsed = await res.json().catch(() => ({}))
throw new ApiError(res.status, toErrorBody(res.status, parsed))
}
return res.json() as Promise<{ url: string }>
}
export function uploadAvatar(file: File) {
return putImageMultipart('/api/users/me/avatar', file)
}
export async function deleteAvatar() {
const res = await users.me.avatar.$delete()
if (!res.ok) {
const parsed = await res.json().catch(() => ({}))
throw new ApiError(res.status, toErrorBody(res.status, parsed))
}
}
export function uploadTeamLogo(teamId: string, file: File) {
return putImageMultipart(`/api/teams/${encodeURIComponent(teamId)}/logo`, file)
}
export async function deleteTeamLogo(teamId: string) {
const res = await teamsApi[':teamId'].logo.$delete({ param: { teamId } })
if (!res.ok) {
const parsed = await res.json().catch(() => ({}))
throw new ApiError(res.status, toErrorBody(res.status, parsed))
}
}
// Branding API
export type { BrandingConfig, BrandingField, BrandingThemeMode, BrandingThemePresetId, BrandingThemeValues }
// PUT uses multipart/form-data (logo/favicon are File objects).
// Hono RPC does not express multipart cleanly — same documented exception as avatar/team logo uploads.
export async function saveBranding(data: {
logo?: File | null
favicon?: File | null
wordmark_text?: string
hide_powered_by?: boolean
theme_mode?: BrandingThemeMode
theme_preset?: BrandingThemePresetId
theme_custom?: BrandingThemeValues
}): Promise<BrandingConfig> {
const form = new FormData()
if (data.logo) form.set('logo', data.logo)
if (data.favicon) form.set('favicon', data.favicon)
// Empty string is submitted as an explicit clear; server treats it as setting wordmark to "".
// Use resetBrandingField('wordmark_text') to fully remove the key.
if (data.wordmark_text !== undefined) form.set('wordmark_text', data.wordmark_text)
if (data.hide_powered_by !== undefined) form.set('hide_powered_by', data.hide_powered_by ? 'true' : 'false')
if (data.theme_mode !== undefined) form.set('theme_mode', data.theme_mode)
if (data.theme_preset !== undefined) form.set('theme_preset', data.theme_preset)
if (data.theme_custom) {
form.set('theme_primary_color', data.theme_custom.primary_color)
form.set('theme_primary_foreground', data.theme_custom.primary_foreground)
form.set('theme_canvas_color', data.theme_custom.canvas_color)
form.set('theme_sidebar_accent_color', data.theme_custom.sidebar_accent_color)
form.set('theme_ring_color', data.theme_custom.ring_color)
}
const res = await fetch('/api/site/settings/branding', {
method: 'PUT',
body: form,
credentials: 'include',
})
if (!res.ok) {
const parsed = await res.json().catch(() => ({}))
throw new ApiError(res.status, toErrorBody(res.status, parsed))
}
return res.json() as Promise<BrandingConfig>
}
export async function resetBrandingField(field: BrandingField): Promise<void> {
const res = await brandingAdminApi[':field'].$delete({ param: { field } })
if (!res.ok) {
const parsed = await res.json().catch(() => ({}))
throw new ApiError(res.status, toErrorBody(res.status, parsed))
}
}
// Admin Audit Logs API
export interface AdminAuditFilter {
orgId?: string
userId?: string
action?: string
targetType?: string
createdFrom?: string
createdTo?: string
}
export function listAdminAuditLogs(page = 1, pageSize = 20, filter: AdminAuditFilter = {}) {
const query: Record<string, string> = {
page: String(page),
pageSize: String(pageSize),
}
if (filter.orgId) query.orgId = filter.orgId
if (filter.userId) query.userId = filter.userId
if (filter.action) query.action = filter.action
if (filter.targetType) query.targetType = filter.targetType
if (filter.createdFrom) query.createdFrom = filter.createdFrom
if (filter.createdTo) query.createdTo = filter.createdTo
return unwrap<PaginatedResponse<AdminAuditEvent>>(adminAuditApi.index.$get({ query }))
}