skillhub/web/src/api/client.ts
XiaoSeS b3b60b4894 Merge remote-tracking branch 'refs/remotes/origin/pr/581' into codex/validate/pr581-20260806
# Conflicts:
#	server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java
#	web/src/pages/dashboard/publish.tsx
2026-08-06 22:24:38 +08:00

1412 lines
47 KiB
TypeScript

import createClient from 'openapi-fetch'
import { BASE_PATH } from '@/shared/lib/base-path'
import type { paths } from './generated/schema'
import type {
ChangePasswordRequest,
PasswordResetConfirmRequest,
PasswordResetRequest,
ApiToken,
CreateTokenRequest,
CreateTokenResponse,
MergeConfirmRequest,
LocalLoginRequest,
LocalRegisterRequest,
MergeInitiateRequest,
MergeInitiateResponse,
MergeVerifyRequest,
ReviewSkillDetail,
ReviewTask,
PromotionSortBy,
PromotionSortDirection,
PromotionStatus,
PromotionTask,
AuditLogItem,
SkillSummary,
SkillReport,
GovernanceSummary,
GovernanceInboxItem,
GovernanceActivityItem,
GovernanceNotification,
PagedResponse,
ReportDisposition,
AuthMethod,
OAuthProvider,
User,
ManagedNamespace,
Namespace,
CreateNamespaceRequest,
NamespaceMember,
NamespaceCandidateUser,
NotificationItem,
NotificationPreferenceItem,
NotificationUnreadCount,
SkillDeleteResult,
AdminLabelInput,
LabelDefinition,
LabelItem,
BatchMemberResponse,
} from './types'
import { ApiError } from '@/shared/lib/api-error'
import i18n from '@/i18n/config'
type OperationQuery<Operation> = Operation extends { parameters: { query?: infer Query } } ? NonNullable<Query> : never
type OperationData<Operation> = Operation extends {
responses: {
200: {
content: {
'*/*': infer Envelope
}
}
}
} ? Envelope extends { data?: infer Data } ? NonNullable<Data> : never : never
type ListMyNamespacesPageOperation = paths['/api/web/me/namespaces/page']['get']
export type MyNamespacePageParams = OperationQuery<ListMyNamespacesPageOperation>
export type MyNamespacePageResponse = OperationData<ListMyNamespacesPageOperation> & PagedResponse<ManagedNamespace>
/**
* Front-end API foundation for generated OpenAPI calls and hand-written convenience wrappers.
*
* This module centralizes runtime-config lookup, CSRF handling, localized request headers, envelope
* unwrapping, and exported API groups used throughout feature hooks.
*/
export { ApiError }
export const WEB_API_PREFIX = '/api/web'
type RuntimeConfig = {
apiBaseUrl?: string
appBaseUrl?: string
authDirectEnabled?: string
authDirectProvider?: string
authSessionBootstrapEnabled?: string
authSessionBootstrapProvider?: string
authSessionBootstrapAuto?: string
}
declare global {
interface Window {
__SKILLHUB_RUNTIME_CONFIG__?: RuntimeConfig
}
}
function getRuntimeConfig(): RuntimeConfig {
if (typeof window === 'undefined') {
return {}
}
return window.__SKILLHUB_RUNTIME_CONFIG__ ?? {}
}
function getApiBaseUrl(): string {
const configured = getRuntimeConfig().apiBaseUrl
if (configured) {
return configured
}
// Default the API prefix to the deployment base path so that setting only
// SKILLHUB_WEB_BASE_PATH (e.g. /skillhub/) still routes API calls to
// /skillhub/api/... instead of /api/... behind a sub-path-only reverse proxy.
// BASE_PATH is import.meta.env.BASE_URL (substituted at container start) and
// always ends with '/'; drop it so requests are /skillhub/api, not /skillhub//api.
return BASE_PATH === '/' ? '' : BASE_PATH.replace(/\/+$/, '')
}
export function getAppBaseUrl(): string {
return getRuntimeConfig().appBaseUrl ?? ''
}
function parseBooleanFlag(value: string | undefined): boolean {
if (!value) {
return false
}
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase())
}
const client = createClient<paths>({ baseUrl: getApiBaseUrl() })
function getCsrfToken(): string | null {
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/)
return match ? decodeURIComponent(match[1]) : null
}
function withRequestHeaders(headers?: HeadersInit): Headers {
const merged = new Headers(headers)
const language = i18n.resolvedLanguage?.trim()
if (language) {
merged.set('Accept-Language', language)
}
return merged
}
function withCsrf(headers?: HeadersInit): HeadersInit {
const merged = withRequestHeaders(headers)
const csrfToken = getCsrfToken()
if (!csrfToken) {
return merged
}
merged.set('X-XSRF-TOKEN', csrfToken)
return merged
}
async function ensureCsrfHeaders(headers?: HeadersInit): Promise<HeadersInit> {
if (!getCsrfToken()) {
await client.GET('/api/v1/auth/providers', {
headers: withRequestHeaders(),
} as never)
}
return withCsrf(headers)
}
function isApiEnvelope<T>(value: unknown): value is ApiEnvelope<T> {
return typeof value === 'object' && value !== null && 'code' in value && 'msg' in value && 'data' in value
}
export function getCsrfHeaders(headers?: HeadersInit): HeadersInit {
return withCsrf(headers)
}
export type SessionBootstrapRuntimeConfig = {
enabled: boolean
provider?: string
auto: boolean
}
export type DirectAuthRuntimeConfig = {
enabled: boolean
provider?: string
}
export function getDirectAuthRuntimeConfig(): DirectAuthRuntimeConfig {
const config = getRuntimeConfig()
const provider = config.authDirectProvider?.trim()
return {
enabled: parseBooleanFlag(config.authDirectEnabled) && !!provider,
provider: provider || undefined,
}
}
export function getSessionBootstrapRuntimeConfig(): SessionBootstrapRuntimeConfig {
const config = getRuntimeConfig()
const provider = config.authSessionBootstrapProvider?.trim()
return {
enabled: parseBooleanFlag(config.authSessionBootstrapEnabled) && !!provider,
provider: provider || undefined,
auto: parseBooleanFlag(config.authSessionBootstrapAuto),
}
}
type ApiEnvelope<T> = {
code: number
msg: string
data: T
timestamp: string
requestId: string
}
type RequestWithTimeout = RequestInit & {
timeoutMs?: number
}
function createRequestSignal(init?: RequestWithTimeout): { signal?: AbortSignal, cleanup: () => void } {
if (!init?.timeoutMs && !init?.signal) {
return { signal: init?.signal ?? undefined, cleanup: () => {} }
}
const controller = new AbortController()
const timeoutId = init?.timeoutMs ? window.setTimeout(() => controller.abort('timeout'), init.timeoutMs) : undefined
const abortListener = () => controller.abort()
if (init?.signal) {
if (init.signal.aborted) {
controller.abort()
} else {
init.signal.addEventListener('abort', abortListener, { once: true })
}
}
return {
signal: controller.signal,
cleanup: () => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId)
}
init?.signal?.removeEventListener('abort', abortListener)
},
}
}
export async function fetchJson<T>(input: RequestInfo | URL, init?: RequestWithTimeout): Promise<T> {
const { signal, cleanup } = createRequestSignal(init)
let response: Response
try {
response = await fetch(withBaseUrl(input), {
...init,
signal,
headers: withRequestHeaders(init?.headers),
})
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new ApiError('error.request.timeout', 408)
}
throw new ApiError('apiError.networkError', 0)
} finally {
cleanup()
}
let json: ApiEnvelope<T> | null = null
try {
json = (await response.json()) as ApiEnvelope<T>
} catch {
if (!response.ok) {
throw new ApiError(`HTTP ${response.status}`, response.status)
}
throw new ApiError('Invalid JSON response', response.status)
}
if (!response.ok || json.code !== 0) {
throw new ApiError(json.msg || `HTTP ${response.status}`, response.status, json.msg, json.msg)
}
return json.data
}
export async function fetchText(input: RequestInfo | URL, init?: RequestInit): Promise<string> {
const response = await fetch(withBaseUrl(input), {
...init,
headers: withRequestHeaders(init?.headers),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.text()
}
function withBaseUrl(input: RequestInfo | URL): RequestInfo | URL {
const baseUrl = getApiBaseUrl()
if (!baseUrl || typeof input !== 'string' || !input.startsWith('/')) {
return input
}
return prependApiBaseUrl(baseUrl, input)
}
export function buildApiUrl(path: string): string {
const baseUrl = getApiBaseUrl()
if (!baseUrl) {
return path
}
return prependApiBaseUrl(baseUrl, path)
}
function prependApiBaseUrl(baseUrl: string, path: string): string {
const normalizedBaseUrl = trimTrailingSlash(baseUrl)
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${normalizedBaseUrl}${normalizedPath}`
}
function trimTrailingSlash(value: string): string {
if (value.length > 1 && value.endsWith('/')) {
return value.slice(0, -1)
}
return value
}
export async function getCurrentUser(): Promise<User | null> {
try {
const user = await fetchJson<User>('/api/v1/auth/me')
return {
...user,
userId: user.userId ?? '',
displayName: user.displayName ?? '',
platformRoles: user.platformRoles ?? [],
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return null
}
throw error
}
}
export const authApi = {
getMe: getCurrentUser,
async getProviders(returnTo?: string): Promise<OAuthProvider[]> {
const params = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ''
const providers = await fetchJson<OAuthProvider[]>(`/api/v1/auth/providers${params}`)
return providers
.filter((provider) => provider.id && provider.name && provider.authorizationUrl)
.map((provider) => ({
...provider,
id: provider.id!,
name: provider.name!,
authorizationUrl: provider.authorizationUrl!,
}))
},
async getMethods(returnTo?: string): Promise<AuthMethod[]> {
const query = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ''
const methods = await fetchJson<AuthMethod[]>(`/api/v1/auth/methods${query}`)
return methods
.filter((method) => method.id && method.methodType && method.provider && method.displayName && method.actionUrl)
.map((method) => ({
...method,
id: method.id,
methodType: method.methodType,
provider: method.provider,
displayName: method.displayName,
actionUrl: method.actionUrl,
}))
},
async localLogin(request: LocalLoginRequest): Promise<User> {
return fetchJson<User>('/api/v1/auth/local/login', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async localRegister(request: LocalRegisterRequest): Promise<User> {
return fetchJson<User>('/api/v1/auth/local/register', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async changePassword(request: ChangePasswordRequest): Promise<void> {
await fetchJson<void>('/api/v1/auth/local/change-password', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async requestPasswordReset(request: PasswordResetRequest): Promise<void> {
await fetchJson<void>('/api/v1/auth/local/password-reset/request', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async confirmPasswordReset(request: PasswordResetConfirmRequest): Promise<void> {
await fetchJson<void>('/api/v1/auth/local/password-reset/confirm', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async logout(): Promise<void> {
const response = await fetch(buildApiUrl('/api/v1/auth/logout'), {
method: 'POST',
headers: withCsrf(),
})
if (response.status !== 200 && response.status !== 204) {
throw new Error(`HTTP ${response.status}`)
}
},
async bootstrapSession(provider: string): Promise<User> {
return fetchJson<User>('/api/v1/auth/session/bootstrap', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ provider }),
})
},
async directLogin(provider: string, request: LocalLoginRequest): Promise<User> {
return fetchJson<User>('/api/v1/auth/direct/login', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({
provider,
username: request.username,
password: request.password,
}),
})
},
}
export const accountApi = {
async initiateMerge(request: MergeInitiateRequest): Promise<MergeInitiateResponse> {
return fetchJson<MergeInitiateResponse>('/api/v1/account/merge/initiate', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async verifyMerge(request: MergeVerifyRequest): Promise<void> {
await fetchJson<void>('/api/v1/account/merge/verify', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async confirmMerge(request: MergeConfirmRequest): Promise<void> {
await fetchJson<void>('/api/v1/account/merge/confirm', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
}
export const skillLifecycleApi = {
async archiveSkill(namespace: string, slug: string, reason?: string): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/archive`, {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}),
})
},
async unarchiveSkill(namespace: string, slug: string): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/unarchive`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async deleteSkill(namespace: string, slug: string, ownerId?: string): Promise<SkillDeleteResult> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
const params = ownerId ? `?ownerId=${encodeURIComponent(ownerId)}` : ''
return fetchJson<SkillDeleteResult>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}${params}`, {
method: 'DELETE',
headers: await ensureCsrfHeaders(),
})
},
async deleteVersion(namespace: string, slug: string, version: string): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`, {
method: 'DELETE',
headers: await ensureCsrfHeaders(),
})
},
async withdrawReview(namespace: string, slug: string, version: string): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/withdraw-review`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string, confirmWarnings = false): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/rerelease`, {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ targetVersion, confirmWarnings }),
})
},
/**
* Submit an UPLOADED version for review.
* Transitions version status from UPLOADED to PENDING_REVIEW.
*/
async submitForReview(namespace: string, slug: string, version: string, targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY'): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/submit-review`, {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ version, targetVisibility }),
})
},
/**
* Confirm publish for a PRIVATE skill version.
* Transitions version status from UPLOADED to PUBLISHED without review.
*/
async confirmPublish(namespace: string, slug: string, version: string): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/confirm-publish`, {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ version }),
})
},
}
function normalizeNamespaceSlug(namespace: string): string {
return namespace.startsWith('@') ? namespace.slice(1) : namespace
}
export const labelApi = {
async listVisible(): Promise<LabelItem[]> {
return fetchJson<LabelItem[]>(`${WEB_API_PREFIX}/labels`)
},
async listSkillLabels(namespace: string, slug: string): Promise<LabelItem[]> {
const cleanNamespace = normalizeNamespaceSlug(namespace)
return fetchJson<LabelItem[]>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/labels`)
},
async attachSkillLabel(namespace: string, slug: string, labelSlug: string): Promise<LabelItem> {
const cleanNamespace = normalizeNamespaceSlug(namespace)
return fetchJson<LabelItem>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/labels/${encodeURIComponent(labelSlug)}`, {
method: 'PUT',
headers: await ensureCsrfHeaders(),
})
},
async detachSkillLabel(namespace: string, slug: string, labelSlug: string): Promise<void> {
const cleanNamespace = normalizeNamespaceSlug(namespace)
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/labels/${encodeURIComponent(labelSlug)}`, {
method: 'DELETE',
headers: await ensureCsrfHeaders(),
})
},
async listAdminDefinitions(): Promise<LabelDefinition[]> {
return fetchJson<LabelDefinition[]>('/api/v1/admin/labels')
},
async createAdminDefinition(request: AdminLabelInput): Promise<LabelDefinition> {
return fetchJson<LabelDefinition>('/api/v1/admin/labels', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({
slug: request.slug.trim(),
type: request.type,
visibleInFilter: request.visibleInFilter,
sortOrder: request.sortOrder,
translations: request.translations.map((translation) => ({
locale: translation.locale.trim(),
displayName: translation.displayName.trim(),
})),
}),
})
},
async updateAdminDefinition(slug: string, request: Omit<AdminLabelInput, 'slug'>): Promise<LabelDefinition> {
return fetchJson<LabelDefinition>(`/api/v1/admin/labels/${encodeURIComponent(slug)}`, {
method: 'PUT',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({
type: request.type,
visibleInFilter: request.visibleInFilter,
sortOrder: request.sortOrder,
translations: request.translations.map((translation) => ({
locale: translation.locale.trim(),
displayName: translation.displayName.trim(),
})),
}),
})
},
async deleteAdminDefinition(slug: string): Promise<void> {
await fetchJson<void>(`/api/v1/admin/labels/${encodeURIComponent(slug)}`, {
method: 'DELETE',
headers: await ensureCsrfHeaders(),
})
},
async updateAdminSortOrder(items: Array<{ slug: string; sortOrder: number }>): Promise<LabelDefinition[]> {
return fetchJson<LabelDefinition[]>('/api/v1/admin/labels/sort-order', {
method: 'PUT',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ items }),
})
},
}
export const namespaceApi = {
async create(request: CreateNamespaceRequest): Promise<Namespace> {
const namespace = await fetchJson<Namespace>('/api/v1/namespaces', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({
slug: normalizeNamespaceSlug(request.slug),
displayName: request.displayName.trim(),
description: request.description?.trim() || undefined,
}),
})
return namespace
},
async listMine(): Promise<ManagedNamespace[]> {
return fetchJson<ManagedNamespace[]>(`${WEB_API_PREFIX}/me/namespaces`)
},
async listMinePage(params: MyNamespacePageParams = {}): Promise<MyNamespacePageResponse> {
const page = params.page ?? 0
const size = params.size ?? 20
const query = new URLSearchParams({ page: String(page), size: String(size) })
params.sort?.forEach((sort) => query.append('sort', sort))
if (params.status) {
query.set('status', params.status)
}
if (params.type) {
query.set('type', params.type)
}
if (params.q?.trim()) {
query.set('q', params.q.trim())
}
if (params.slug?.trim()) {
query.set('slug', normalizeNamespaceSlug(params.slug))
}
params.roles?.forEach((role) => query.append('roles', role))
return fetchJson<MyNamespacePageResponse>(`${WEB_API_PREFIX}/me/namespaces/page?${query.toString()}`)
},
async getDetail(slug: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`)
},
async freeze(slug: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/freeze`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async unfreeze(slug: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/unfreeze`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async archive(slug: string, reason?: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/archive`, {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}),
})
},
async restore(slug: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/restore`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async delete(slug: string): Promise<void> {
await fetchJson<void>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`, {
method: 'DELETE',
headers: await ensureCsrfHeaders(),
})
},
async listMembers(slug: string, params?: { page?: number; size?: number }): Promise<PagedResponse<NamespaceMember>> {
const queryPage = params?.page ?? 0
const querySize = params?.size ?? 20
return fetchJson<PagedResponse<NamespaceMember>>(
`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members?page=${queryPage}&size=${querySize}`,
)
},
async searchMemberCandidates(slug: string, search: string, size = 10): Promise<NamespaceCandidateUser[]> {
const query = new URLSearchParams({
search: search.trim(),
size: String(size),
})
return fetchJson<NamespaceCandidateUser[]>(
`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/member-candidates?${query.toString()}`,
)
},
async addMember(slug: string, request: { userId: string; role: string }): Promise<NamespaceMember> {
return fetchJson<NamespaceMember>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members`, {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({
userId: request.userId.trim(),
role: request.role,
}),
})
},
async batchAddMembers(slug: string, members: Array<{ userId: string; role: string }>): Promise<BatchMemberResponse> {
return fetchJson<BatchMemberResponse>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/batch`, {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ members }),
})
},
async updateMemberRole(slug: string, userId: string, role: string): Promise<NamespaceMember> {
return fetchJson<NamespaceMember>(
`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}/role`,
{
method: 'PUT',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ role }),
},
)
},
async removeMember(slug: string, userId: string): Promise<void> {
await fetchJson<void>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}`, {
method: 'DELETE',
headers: await ensureCsrfHeaders(),
})
},
async update(slug: string, request: { displayName?: string; description?: string }): Promise<Namespace> {
const body: Record<string, string> = {}
if (request.displayName !== undefined) {
body.displayName = request.displayName.trim()
}
if (request.description !== undefined) {
body.description = request.description === '' ? '' : request.description.trim()
}
return fetchJson<Namespace>(`/api/v1/namespaces/${normalizeNamespaceSlug(slug)}`, {
method: 'PUT',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(body),
})
},
async transferOwnership(slug: string, newOwnerUserId: string): Promise<{ message: string }> {
return fetchJson<{ message: string }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/transfer-ownership`, {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ newOwnerId: newOwnerUserId.trim() }),
})
},
}
export const tokenApi = {
async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> {
const queryPage = params?.page ?? 0
const querySize = params?.size ?? 10
const page = await fetchJson<{ items: ApiToken[], total: number, page: number, size: number }>(
`/api/v1/tokens?page=${queryPage}&size=${querySize}`,
)
return {
...page,
items: page.items
.filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt)
.map((token) => ({
...token,
id: token.id!,
name: token.name!,
tokenPrefix: token.tokenPrefix!,
createdAt: token.createdAt!,
})),
}
},
async createToken(request: CreateTokenRequest): Promise<CreateTokenResponse> {
const token = await fetchJson<CreateTokenResponse>('/api/v1/tokens', {
method: 'POST',
headers: withCsrf({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
if (!token.token || token.id === undefined || !token.name || !token.tokenPrefix || !token.createdAt) {
throw new Error('Invalid token creation response')
}
return {
...token,
token: token.token,
id: token.id,
name: token.name,
tokenPrefix: token.tokenPrefix,
createdAt: token.createdAt,
}
},
async updateTokenExpiration(tokenId: number, expiresAt?: string): Promise<ApiToken> {
return fetchJson<ApiToken>(`/api/v1/tokens/${tokenId}/expiration`, {
method: 'PUT',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ expiresAt: expiresAt ?? '' }),
})
},
async deleteToken(tokenId: number): Promise<void> {
const { error, response } = await client.DELETE('/api/v1/tokens/{id}', {
params: {
path: {
id: tokenId,
},
},
headers: withCsrf(),
} as never)
if (response.status === 204) {
return
}
const envelope = (error && isApiEnvelope<void>(error) ? error : null) as { msg?: string } | null
if (!response.ok || error) {
throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg)
}
},
}
export const reviewApi = {
async list(params: { status: string; namespaceId?: number; page?: number; size?: number; sortDirection?: 'ASC' | 'DESC' }) {
const searchParams = new URLSearchParams()
searchParams.set('status', params.status)
if (params.namespaceId !== undefined) {
searchParams.set('namespaceId', String(params.namespaceId))
}
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
searchParams.set('sortDirection', params.sortDirection ?? 'DESC')
return fetchJson<{ items: ReviewTask[]; total: number; page: number; size: number }>(
`${WEB_API_PREFIX}/reviews?${searchParams.toString()}`,
)
},
async get(id: number): Promise<ReviewTask> {
return fetchJson<ReviewTask>(`${WEB_API_PREFIX}/reviews/${id}`)
},
async getSkillDetail(id: number): Promise<ReviewSkillDetail> {
return fetchJson<ReviewSkillDetail>(`${WEB_API_PREFIX}/reviews/${id}/skill-detail`)
},
async approve(id: number, comment?: string): Promise<void> {
await fetchJson<void>(`${WEB_API_PREFIX}/reviews/${id}/approve`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ comment }),
})
},
async reject(id: number, comment: string): Promise<void> {
await fetchJson<void>(`${WEB_API_PREFIX}/reviews/${id}/reject`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ comment }),
})
},
}
export const promotionApi = {
async submit(request: { sourceSkillId: number; sourceVersionId: number; targetNamespaceId: number }): Promise<void> {
await fetchJson<void>(`${WEB_API_PREFIX}/promotions`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async list(params: { status?: PromotionStatus; page?: number; size?: number; sortBy?: PromotionSortBy; sortDirection?: PromotionSortDirection }) {
const searchParams = new URLSearchParams()
searchParams.set('status', params.status ?? 'PENDING')
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
if (params.sortBy) {
searchParams.set('sortBy', params.sortBy)
}
if (params.sortDirection) {
searchParams.set('sortDirection', params.sortDirection)
}
return fetchJson<{ items: PromotionTask[]; total: number; page: number; size: number }>(
`${WEB_API_PREFIX}/promotions?${searchParams.toString()}`,
)
},
async get(id: number): Promise<PromotionTask> {
return fetchJson<PromotionTask>(`${WEB_API_PREFIX}/promotions/${id}`)
},
async approve(id: number, comment?: string): Promise<void> {
await fetchJson<void>(`${WEB_API_PREFIX}/promotions/${id}/approve`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ comment }),
})
},
async reject(id: number, comment?: string): Promise<void> {
await fetchJson<void>(`${WEB_API_PREFIX}/promotions/${id}/reject`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ comment }),
})
},
}
export const reportApi = {
async submitSkillReport(namespace: string, slug: string, request: { reason: string; details?: string }): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/reports`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async listSkillReports(params: { status?: string; page?: number; size?: number }) {
const searchParams = new URLSearchParams()
searchParams.set('status', params.status ?? 'PENDING')
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
return fetchJson<{ items: SkillReport[]; total: number; page: number; size: number }>(
`/api/v1/admin/skill-reports?${searchParams.toString()}`,
)
},
async resolveSkillReport(id: number, comment?: string, disposition: ReportDisposition = 'RESOLVE_ONLY'): Promise<void> {
await fetchJson<void>(`/api/v1/admin/skill-reports/${id}/resolve`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ comment, disposition }),
})
},
async dismissSkillReport(id: number, comment?: string): Promise<void> {
await fetchJson<void>(`/api/v1/admin/skill-reports/${id}/dismiss`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ comment }),
})
},
}
export const governanceApi = {
async getSummary(): Promise<GovernanceSummary> {
return fetchJson<GovernanceSummary>(`${WEB_API_PREFIX}/governance/summary`)
},
async getInbox(params: { type?: string; page?: number; size?: number }) {
const searchParams = new URLSearchParams()
if (params.type) searchParams.set('type', params.type)
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
return fetchJson<PagedResponse<GovernanceInboxItem>>(
`${WEB_API_PREFIX}/governance/inbox?${searchParams.toString()}`,
)
},
async getActivity(params: { page?: number; size?: number }) {
const searchParams = new URLSearchParams()
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
return fetchJson<PagedResponse<GovernanceActivityItem>>(
`${WEB_API_PREFIX}/governance/activity?${searchParams.toString()}`,
)
},
async getNotifications(params: { page?: number; size?: number }): Promise<PagedResponse<GovernanceNotification>> {
const searchParams = new URLSearchParams()
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
return fetchJson<PagedResponse<GovernanceNotification>>(`${WEB_API_PREFIX}/governance/notifications?${searchParams.toString()}`)
},
async markNotificationRead(id: number): Promise<GovernanceNotification> {
return fetchJson<GovernanceNotification>(`${WEB_API_PREFIX}/governance/notifications/${id}/read`, {
method: 'POST',
headers: getCsrfHeaders(),
})
},
async rebuildSearchIndex(): Promise<void> {
await fetchJson<void>('/api/v1/admin/search/rebuild', {
method: 'POST',
headers: getCsrfHeaders(),
})
},
}
export const meApi = {
async getSkills(params?: { page?: number; size?: number; filter?: string; q?: string; namespace?: string }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> {
const searchParams = new URLSearchParams()
searchParams.set('page', String(params?.page ?? 0))
searchParams.set('size', String(params?.size ?? 10))
if (params?.filter) {
searchParams.set('filter', params.filter)
}
if (params?.q) {
searchParams.set('q', params.q)
}
if (params?.namespace) {
searchParams.set('namespace', params.namespace)
}
return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/skills?${searchParams.toString()}`)
},
async getStarsPage(params?: { page?: number; size?: number }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> {
const searchParams = new URLSearchParams()
searchParams.set('page', String(params?.page ?? 0))
searchParams.set('size', String(params?.size ?? 12))
return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/stars?${searchParams.toString()}`)
},
async getStars(): Promise<SkillSummary[]> {
const items: SkillSummary[] = []
let page = 0
const size = 100
let hasMore = true
while (hasMore) {
const response = await meApi.getStarsPage({ page, size })
items.push(...response.items)
hasMore = (page + 1) * response.size < response.total && response.items.length > 0
page += 1
}
return items
},
async getSubscriptionsPage(params?: { page?: number; size?: number }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> {
const searchParams = new URLSearchParams()
searchParams.set('page', String(params?.page ?? 0))
searchParams.set('size', String(params?.size ?? 12))
return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/subscriptions?${searchParams.toString()}`)
},
async getSubscriptions(): Promise<SkillSummary[]> {
const items: SkillSummary[] = []
let page = 0
const size = 100
let hasMore = true
while (hasMore) {
const response = await meApi.getSubscriptionsPage({ page, size })
items.push(...response.items)
hasMore = (page + 1) * response.size < response.total && response.items.length > 0
page++
}
return items
},
}
export const profileApi = {
async getProfile(): Promise<{
displayName: string
avatarUrl: string | null
email: string | null
pendingChanges: {
status: string
changes: Record<string, string>
reviewComment: string | null
createdAt: string
} | null
fieldPolicies: Record<string, { editable: boolean; requiresReview: boolean }>
}> {
return fetchJson('/api/v1/user/profile')
},
async updateProfile(request: Record<string, string>): Promise<{
status: string
appliedFields?: Record<string, string>
pendingFields?: Record<string, string>
}> {
return fetchJson<{ status: string; appliedFields?: Record<string, string>; pendingFields?: Record<string, string> }>('/api/v1/user/profile', {
method: 'PATCH',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
}
export const adminApi = {
async getUsers(params: { search?: string; status?: string; page?: number; size?: number }) {
const searchParams = new URLSearchParams()
if (params.search) searchParams.set('search', params.search)
if (params.status) searchParams.set('status', params.status)
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
const response = await fetchJson<{
items: Array<{
id: string
username: string
email?: string
platformRoles?: string[]
status: string
createdAt: string
}>
total: number
page: number
size: number
}>(
`/api/v1/admin/users?${searchParams.toString()}`,
)
return {
...response,
items: response.items
.filter((user) => user.id && user.username && user.status && user.createdAt)
.map((user) => ({
userId: user.id,
username: user.username,
email: user.email,
platformRoles: user.platformRoles ?? [],
status: user.status,
createdAt: user.createdAt,
})),
}
},
async updateUserRole(userId: string, role: string): Promise<void> {
await fetchJson<void>(`/api/v1/admin/users/${userId}/role`, {
method: 'PUT',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ role }),
})
},
async updateUserStatus(userId: string, status: string): Promise<void> {
await fetchJson<void>(`/api/v1/admin/users/${userId}/status`, {
method: 'PUT',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ status }),
})
},
async approveUser(userId: string): Promise<void> {
await fetchJson<void>(`/api/v1/admin/users/${userId}/approve`, {
method: 'POST',
headers: getCsrfHeaders(),
})
},
async disableUser(userId: string): Promise<void> {
await fetchJson<void>(`/api/v1/admin/users/${userId}/disable`, {
method: 'POST',
headers: getCsrfHeaders(),
})
},
async enableUser(userId: string): Promise<void> {
await fetchJson<void>(`/api/v1/admin/users/${userId}/enable`, {
method: 'POST',
headers: getCsrfHeaders(),
})
},
async triggerPasswordReset(userId: string): Promise<void> {
await fetchJson<void>(`/api/v1/admin/users/${userId}/password-reset`, {
method: 'POST',
headers: getCsrfHeaders(),
})
},
async getAuditLogs(params: {
action?: string
userId?: string
requestId?: string
ipAddress?: string
resourceType?: string
resourceId?: string
startTime?: string
endTime?: string
page?: number
size?: number
}) {
const searchParams = new URLSearchParams()
if (params.action) searchParams.set('action', params.action)
if (params.userId) searchParams.set('userId', params.userId)
if (params.requestId) searchParams.set('requestId', params.requestId)
if (params.ipAddress) searchParams.set('ipAddress', params.ipAddress)
if (params.resourceType) searchParams.set('resourceType', params.resourceType)
if (params.resourceId) searchParams.set('resourceId', params.resourceId)
if (params.startTime) searchParams.set('startTime', params.startTime)
if (params.endTime) searchParams.set('endTime', params.endTime)
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
return fetchJson<{ items: AuditLogItem[]; total: number; page: number; size: number }>(
`/api/v1/admin/audit-logs?${searchParams.toString()}`,
)
},
async hideSkill(skillId: number, reason?: string): Promise<void> {
await fetchJson<void>(`/api/v1/admin/skills/${skillId}/hide`, {
method: 'POST',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ reason }),
})
},
async unhideSkill(skillId: number): Promise<void> {
await fetchJson<void>(`/api/v1/admin/skills/${skillId}/unhide`, {
method: 'POST',
headers: getCsrfHeaders(),
})
},
async yankVersion(versionId: number, reason?: string): Promise<void> {
await fetchJson<void>(`/api/v1/admin/skills/versions/${versionId}/yank`, {
method: 'POST',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ reason }),
})
},
async getProfileReviews(params: { status?: string; page?: number; size?: number; sortDirection?: 'ASC' | 'DESC' }) {
const searchParams = new URLSearchParams()
if (params.status) searchParams.set('status', params.status)
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
searchParams.set('sortDirection', params.sortDirection ?? 'DESC')
const response = await fetchJson<{
items: Array<{
id: number
userId: string
username: string
currentDisplayName: string | null
requestedDisplayName: string | null
status: string
machineResult: string | null
reviewerId: string | null
reviewerName: string | null
reviewComment: string | null
createdAt: string
reviewedAt: string | null
}>
total: number
page: number
size: number
}>(`/api/v1/admin/profile-reviews?${searchParams}`)
return {
...response,
totalElements: response.total,
totalPages: response.size > 0 ? Math.ceil(response.total / response.size) : 0,
}
},
async approveProfileReview(id: number): Promise<void> {
await fetchJson<void>(`/api/v1/admin/profile-reviews/${id}/approve`, {
method: 'POST',
headers: getCsrfHeaders(),
})
},
async rejectProfileReview(id: number, comment: string): Promise<void> {
await fetchJson<void>(`/api/v1/admin/profile-reviews/${id}/reject`, {
method: 'POST',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ comment }),
})
},
}
export const notificationApi = {
async list(params: { page?: number; size?: number; category?: string }) {
const searchParams = new URLSearchParams()
if (params.page !== undefined) searchParams.set('page', String(params.page))
if (params.size !== undefined) searchParams.set('size', String(params.size))
if (params.category) searchParams.set('category', params.category)
return fetchJson<{ items: NotificationItem[]; total: number; page: number; size: number }>(
`${WEB_API_PREFIX}/notifications?${searchParams.toString()}`,
)
},
async getUnreadCount() {
return fetchJson<NotificationUnreadCount>(`${WEB_API_PREFIX}/notifications/unread-count`)
},
async markRead(id: number) {
await fetchJson<void>(`${WEB_API_PREFIX}/notifications/${id}/read`, {
method: 'PUT',
headers: getCsrfHeaders(),
})
},
async markAllRead() {
return fetchJson<{ count: number }>(`${WEB_API_PREFIX}/notifications/read-all`, {
method: 'PUT',
headers: getCsrfHeaders(),
})
},
async deleteRead(id: number) {
await fetchJson<void>(`${WEB_API_PREFIX}/notifications/${id}`, {
method: 'DELETE',
headers: getCsrfHeaders(),
})
},
async getPreferences() {
return fetchJson<NotificationPreferenceItem[]>(`${WEB_API_PREFIX}/notification-preferences`)
},
async updatePreferences(preferences: NotificationPreferenceItem[]) {
await fetchJson<void>(`${WEB_API_PREFIX}/notification-preferences`, {
method: 'PUT',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ preferences }),
})
},
}