feat(admin): 예전/최신 어드민 통합 — 실데이터 복원 + 인증 아키텍처 정리

예전 배포본(HEAD)의 상세 기능을 새 아키텍처(인증=.NET 백엔드, 데이터=Supabase)
위에 실데이터로 복원. 예전 HEAD는 서명 쿠키를 거부하고 위조 쿠키는 통과시키는
인증 결함이 있었고, 삭제된 페이지 다수는 백엔드 호출 0인 하드코딩 목업이었음.

인증/세션
- 로그인 이메일 전용화(username 폐지), 에러 키별 안내 메시지
- ADMIN_COOKIE_SECURE 옵션: TLS 없는 LAN HTTP 배포에서 Secure 쿠키 유실로
  로그인이 유지되지 않던 문제 해결 (login/logout route, admin-session, compose, .env.example)
- Supabase 미설정 시 우아한 저하: isSupabaseAdminConfigured + UnavailableAdminPanel

기능 복원 (실데이터)
- Release Hub: Forgejo API 실데이터(다운로드 수/SHA-256 체크섬/릴리스 이력)
- Ad Monetization: 데스크톱 미디에이션 10개 어댑터 로스터(fail-closed) + ad_reward_claims 통계
- License Issuer: 서버사이드 Ed25519 서명(/api/admin/license, super_admin 전용),
  개인키는 ADMIN_LICENSE_PRIVATE_KEY env로만, 발급 감사를 .NET AdminAuditEntries에 기록
- Service Models: STT 7종/LLM 5종 프리셋 드롭다운 + 자동채움
- 대시보드 ARR/MRR KPI: Supabase 구독 실집계(티어 월단가 기반)
- 사용자 상세 티어별 기능 배지(pro_plus 조건부)

.NET
- SuperAdminOnly 정책 추가, /api/admin/license-audit 엔드포인트, LicenseAuditDto
This commit is contained in:
Yun Chan 2026-08-23 23:38:08 +09:00
parent a9c9a1ca6e
commit 5a34f66981
66 changed files with 4471 additions and 3501 deletions

View file

@ -0,0 +1,98 @@
import 'server-only'
import type { AdFormat, AdNetworkId } from '@d3ro/core/types'
import { getSupabaseAdminClient } from './supabase-admin'
export type AdIntegrationStatus = 'fail_closed'
export interface MediationNetworkEntry {
networkId: AdNetworkId
name: string
formats: AdFormat[]
floorEcpm: number
integration: AdIntegrationStatus
}
// apps/desktop AdMediationEngine 어댑터 등록부의 미러 — 데스크톱에서 어댑터를
// 추가/변경하면 이 로스터도 함께 갱신해야 한다. 모든 어댑터는 현재 fail-closed
// (bid 없음, 리워드 미지급) 상태이므로 통합 상태를 실측 없이 live로 표기하지 않는다.
export const MEDIATION_ROSTER: MediationNetworkEntry[] = [
{ networkId: 'direct_sponsor', name: 'Direct House Sponsor', formats: ['banner_dock', 'rewarded_video', 'export_sponsor'], floorEcpm: 12, integration: 'fail_closed' },
{ networkId: 'playwire', name: 'Playwire RAMP', formats: ['banner_dock', 'rewarded_video', 'export_sponsor'], floorEcpm: 4.5, integration: 'fail_closed' },
{ networkId: 'ethical_ads', name: 'EthicalAds', formats: ['banner_dock', 'export_sponsor'], floorEcpm: 3.2, integration: 'fail_closed' },
{ networkId: 'carbon_ads', name: 'Carbon Ads', formats: ['banner_dock'], floorEcpm: 3.5, integration: 'fail_closed' },
{ networkId: 'unity_ads', name: 'Unity LevelPlay', formats: ['rewarded_video', 'banner_dock'], floorEcpm: 6, integration: 'fail_closed' },
{ networkId: 'applovin_max', name: 'AppLovin MAX', formats: ['rewarded_video', 'banner_dock', 'export_sponsor'], floorEcpm: 5.5, integration: 'fail_closed' },
{ networkId: 'google_ad_manager', name: 'Google Ad Manager', formats: ['banner_dock', 'rewarded_video', 'export_sponsor'], floorEcpm: 2, integration: 'fail_closed' },
{ networkId: 'inmobi', name: 'InMobi', formats: ['banner_dock', 'rewarded_video'], floorEcpm: 2.8, integration: 'fail_closed' },
{ networkId: 'pubmatic', name: 'PubMatic OpenWrap', formats: ['banner_dock', 'export_sponsor'], floorEcpm: 3, integration: 'fail_closed' },
{ networkId: 'mintegral', name: 'Mintegral', formats: ['rewarded_video', 'banner_dock'], floorEcpm: 4, integration: 'fail_closed' }
]
export interface AdRewardNetworkSummary {
network: string
claims: number
rewardTokens: number
}
export interface AdRewardClaimRow {
id: string
network: string
placement: string
rewardTokens: number
verifiedAt: string
}
export interface AdRewardStats {
windowDays: number
totalClaims: number
totalRewardTokens: number
uniqueClaimants: number
byNetwork: AdRewardNetworkSummary[]
recentClaims: AdRewardClaimRow[]
}
export async function fetchAdRewardStats(windowDays = 30): Promise<AdRewardStats> {
const supabase = await getSupabaseAdminClient('manager')
const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1_000).toISOString()
const { data, error } = await supabase
.from('ad_reward_claims')
.select('id, user_id, network, placement, reward_tokens, verified_at')
.gte('verified_at', since)
.order('verified_at', { ascending: false })
.limit(2_000)
if (error) throw new Error('Supabase ad reward claims query failed')
const rows = (data ?? []).filter(
(row): row is { id: string; user_id: string; network: string; placement: string; reward_tokens: number; verified_at: string } =>
typeof row?.id === 'string' &&
typeof row?.user_id === 'string' &&
typeof row?.network === 'string' &&
typeof row?.reward_tokens === 'number' &&
typeof row?.verified_at === 'string'
)
const byNetworkMap = new Map<string, AdRewardNetworkSummary>()
for (const row of rows) {
const summary = byNetworkMap.get(row.network) ?? { network: row.network, claims: 0, rewardTokens: 0 }
summary.claims += 1
summary.rewardTokens += row.reward_tokens
byNetworkMap.set(row.network, summary)
}
return {
windowDays,
totalClaims: rows.length,
totalRewardTokens: rows.reduce((sum, row) => sum + row.reward_tokens, 0),
uniqueClaimants: new Set(rows.map((row) => row.user_id)).size,
byNetwork: [...byNetworkMap.values()].sort((a, b) => b.rewardTokens - a.rewardTokens),
recentClaims: rows.slice(0, 20).map((row) => ({
id: row.id,
network: row.network,
placement: typeof row.placement === 'string' ? row.placement : '—',
rewardTokens: row.reward_tokens,
verifiedAt: row.verified_at
}))
}
}

View file

@ -1,9 +1,4 @@
// apps/admin/src/lib/admin-api.ts
// Edge Function 호출 헬퍼 — 클라이언트 컴포넌트용
import { getSupabaseBrowserClient } from './supabase-browser'
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
'use client'
interface AdminApiOptions extends Omit<RequestInit, 'headers'> {
headers?: Record<string, string>
@ -13,33 +8,25 @@ export async function callAdminApi<T = Record<string, unknown>>(
path: string,
options: AdminApiOptions = {}
): Promise<T> {
const supabase = getSupabaseBrowserClient()
// getUser()로 토큰 갱신을 트리거한 뒤 세션에서 access_token 획득
const { data: { user }, error: userError } = await supabase.auth.getUser()
if (userError || !user) {
throw new Error('Not authenticated — please re-login')
const [operation, query = ''] = path.split('?', 2)
if (!/^admin-[a-z-]+$/.test(operation)) throw new Error('Invalid admin operation')
const method = (options.method ?? 'GET').toUpperCase()
const headers: Record<string, string> = {
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...options.headers
}
if (method !== 'GET' && !headers['Idempotency-Key']) {
headers['Idempotency-Key'] = crypto.randomUUID()
}
const { data: { session } } = await supabase.auth.getSession()
if (!session?.access_token) {
throw new Error('Not authenticated — session expired')
}
const response = await fetch(`${SUPABASE_URL}/functions/v1/${path}`, {
const response = await fetch(`/api/admin/supabase/${operation}${query ? `?${query}` : ''}`, {
...options,
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
...options.headers,
},
method,
headers,
cache: 'no-store'
})
const data = await response.json() as T & { error?: string }
if (!response.ok) {
throw new Error(data.error ?? `API error: ${response.status}`)
}
return data
const payload = await response.json().catch(() => null) as T & { error?: string }
if (!response.ok) throw new Error(payload?.error ?? `Admin API error (${response.status})`)
return payload
}

View file

@ -3,8 +3,10 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { verifySession } from './security'
import type { AdminRole } from './admin-session'
export type AdminRole = 'manager' | 'admin' | 'super_admin'
export type { AdminRole } from './admin-session'
export interface AdminUser {
id: string
@ -22,21 +24,17 @@ export async function requireManager(): Promise<AdminUser> {
redirect('/login')
}
try {
const decoded = JSON.parse(Buffer.from(sessionCookie, 'base64').toString('utf-8'))
if (!decoded || !decoded.expiresAt || decoded.expiresAt <= Date.now()) {
redirect('/login')
}
return {
id: decoded.id || 'admin-usr-1',
email: decoded.email || 'admin@d3ro.voice',
name: decoded.username === 'admin' ? 'Master Admin' : decoded.email,
role: (decoded.role as AdminRole) || 'super_admin',
}
} catch {
const session = verifySession(sessionCookie)
if (!session) {
redirect('/login')
}
return {
id: session.email,
email: session.email,
name: session.email,
role: session.role
}
}
/** admin 이상 */

View file

@ -0,0 +1,72 @@
export type AdminRole = 'manager' | 'admin' | 'super_admin'
export interface AdminSession {
email: string
role: AdminRole
token: string
loginAt: string
expiresAt: number
}
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/
export function requireAdminSessionSecret(): string {
const value = process.env.ADMIN_SESSION_SECRET?.trim()
if (!value || new TextEncoder().encode(value).byteLength < 32) {
throw new Error('ADMIN_SESSION_SECRET must contain at least 32 bytes')
}
return value
}
export function parseAdminSession(value: unknown, now = Date.now()): AdminSession | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const candidate = value as Record<string, unknown>
const email = typeof candidate.email === 'string' ? candidate.email.trim().toLowerCase() : ''
const role = candidate.role
const token = candidate.token
const loginAt = candidate.loginAt
const expiresAt = candidate.expiresAt
if (!email || email.length > 150 || !email.includes('@')) return null
if (role !== 'manager' && role !== 'admin' && role !== 'super_admin') return null
if (typeof token !== 'string' || token.length < 80 || token.length > 8_192) return null
if (typeof loginAt !== 'string' || !Number.isFinite(Date.parse(loginAt))) return null
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || expiresAt <= now) return null
return { email, role, token, loginAt, expiresAt }
}
export function decodeBase64Url(value: string): Uint8Array | null {
if (!value || !BASE64URL_PATTERN.test(value)) return null
try {
const padding = '='.repeat((4 - (value.length % 4)) % 4)
const binary = atob(value.replace(/-/g, '+').replace(/_/g, '/') + padding)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return bytes
} catch {
return null
}
}
export function decodeAdminSessionPayload(encodedPayload: string): AdminSession | null {
const bytes = decodeBase64Url(encodedPayload)
if (!bytes || bytes.byteLength === 0 || bytes.byteLength > 16_384) return null
try {
return parseAdminSession(JSON.parse(new TextDecoder().decode(bytes)))
} catch {
return null
}
}
// ADMIN_COOKIE_SECURE=false: TLS 종료 프록시 없이 LAN HTTP(예: http://NAS_IP:3001)로
// 직접 접속하는 배포에서만 사용. Secure 쿠키는 HTTP에서 브라우저가 버려 로그인이 유지되지 않는다.
export function adminCookieSecure(): boolean {
if (process.env.ADMIN_COOKIE_SECURE === 'false') return false
return process.env.NODE_ENV === 'production'
}

View file

@ -1,7 +1,6 @@
// apps/admin/src/lib/api-server.ts
// Helper library for connecting Next.js apps/admin to C# .NET API Backend & High-Fidelity D3RO Telemetry
import 'server-only'
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'
import { fetchAdminBackend } from './backend-session'
export interface SystemNodeHealth {
id: string
@ -16,44 +15,24 @@ export interface SystemNodeHealth {
}
export interface PipelineStats {
whisper: {
engine: string
activeModel: string
avgLatencyMs: number
speedupFactor: string
partialStreamingFps: number
totalTranscriptionsToday: number
gpuVramUsage: string
}
ollama: {
version: string
loadedModels: string[]
activeContextLimit: number
tokensPerSecond: number
vramAllocated: string
activeSessions: number
}
realtimeVoice: {
backend: string
activeStreams: number
streamUptime: number
localFallbackRate: string
avgAudioRttMs: number
}
ragVector: {
embeddingModel: string
indexedDocuments: number
totalVectorChunks: number
avgSearchLatencyMs: number
topHitRatePercent: number
}
meetingIntelligence: {
diarizationEngine: string
speakerAccuracyPercent: number
activeMeetingSessions: number
templatesGeneratedToday: number
mindmapsExported: number
}
whisper: { engine: string; activeModel: string; avgLatencyMs: number; speedupFactor: string; partialStreamingFps: number; totalTranscriptionsToday: number; gpuVramUsage: string }
ollama: { version: string; loadedModels: string[]; activeContextLimit: number; tokensPerSecond: number; vramAllocated: string; activeSessions: number }
realtimeVoice: { backend: string; activeStreams: number; streamUptime: number; localFallbackRate: string; avgAudioRttMs: number }
ragVector: { embeddingModel: string; indexedDocuments: number; totalVectorChunks: number; avgSearchLatencyMs: number; topHitRatePercent: number }
meetingIntelligence: { diarizationEngine: string; speakerAccuracyPercent: number; activeMeetingSessions: number; templatesGeneratedToday: number; mindmapsExported: number }
}
export interface FeatureUsageBreakdown {
featureId: string
featureName: string
category: string
totalCalls?: number
callCount?: number
percentage?: number
tokensUsed: number
totalCost: number
estimatedCostUsd?: number
avgLatencyMs: number
}
export interface ServerStats {
@ -63,44 +42,27 @@ export interface ServerStats {
totalCost: number
serverUptimeSeconds: number
errorCount: number
arrUsd: number
mrrUsd: number
tierDistribution: {
free: number
pro: number
pro_plus: number
}
arrUsd: number | null
mrrUsd: number | null
tierDistribution: { free: number; pro: number; pro_plus: number } | null
nodes: SystemNodeHealth[]
pipelines: PipelineStats
pipelines: PipelineStats | null
featureBreakdown: FeatureUsageBreakdown[]
recentErrors: Array<{
id: number
errorType: string
message: string
endpoint: string | null
createdAt: string
}>
recentErrors: Array<{ id: number; errorType: string; message: string; endpoint: string | null; createdAt: string }>
}
export interface UserItem {
id: number
id: string
uid: string
email: string
name: string
role: 'user' | 'manager' | 'admin' | 'super_admin'
tier: 'free' | 'pro' | 'pro_plus'
tier: 'free' | 'pro' | 'pro_plus' | null
createdAt: string
lastLoginAt: string | null
lastActiveDevice: string
lastActiveDevice: string | null
isActive: boolean
dailyUsage: {
dictations: number
dictationsMax: number
llmCalls: number
llmCallsMax: number
ragQueries: number
ragQueriesMax: number
}
dailyUsage: { dictations: number; dictationsMax: number | null; llmCalls: number; llmCallsMax: number | null; ragQueries: number; ragQueriesMax: number | null } | null
}
export interface ModelEndpoint {
@ -112,21 +74,13 @@ export interface ModelEndpoint {
apiKey: string
costPer1kPromptTokens: number
costPer1kCompletionTokens: number
latencyMs: number
latencyMs?: number
isActive: boolean
isDefault: boolean
isDefault?: boolean
createdAt: string
}
export type STTProviderCategory =
| 'groq'
| 'openai'
| 'deepgram'
| 'google'
| 'assemblyai'
| 'azure'
| 'custom'
| 'local-sidecar'
export type STTProviderCategory = 'groq' | 'openai' | 'deepgram' | 'google' | 'assemblyai' | 'azure' | 'custom' | 'local-sidecar'
export interface SttProviderEndpoint {
id: number
@ -166,25 +120,10 @@ export interface CreateSttEndpointDto {
isActive?: boolean
fallbackPriority?: number
extraHeadersJson?: string
memo: string
}
export interface UpdateSttEndpointDto {
name: string
providerType: STTProviderCategory
endpointUrl: string
apiKey?: string
modelId: string
method: string
language?: string
prompt?: string
temperature?: number
costPerMinute: number
costPerSecond?: number
isDefault?: boolean
isActive?: boolean
fallbackPriority?: number
extraHeadersJson?: string
}
export type UpdateSttEndpointDto = CreateSttEndpointDto
export interface SttTestResult {
success: boolean
@ -200,34 +139,8 @@ export interface SttUsageReport {
totalAudioMinutes: number
totalCost: number
avgLatencyMs: number
providerSummaries: Array<{
provider: string
modelId: string
totalRequests: number
totalAudioMinutes: number
totalCost: number
avgLatencyMs: number
}>
userSummaries: Array<{
userId: number
email: string
totalRequests: number
totalAudioMinutes: number
totalCost: number
}>
}
export interface FeatureUsageBreakdown {
featureId: string
featureName: string
category: string
totalCalls?: number
callCount?: number
percentage?: number
tokensUsed: number
totalCost: number
estimatedCostUsd?: number
avgLatencyMs: number
providerSummaries: Array<{ provider: string; modelId: string; totalRequests: number; totalAudioMinutes: number; totalCost: number; avgLatencyMs: number }>
userSummaries: Array<{ userId: number; email: string; totalRequests: number; totalAudioMinutes: number; totalCost: number }>
}
export interface UsageReport {
@ -235,712 +148,92 @@ export interface UsageReport {
totalPromptTokens: number
totalCompletionTokens: number
totalCost: number
timeline: Array<{
date: string
dictations: number
meetingSummaries: number
aiChat: number
ragSearch: number
voiceRealtime: number
totalCost: number
}>
timeline: Array<{ date: string; dictations: number; meetingSummaries: number; aiChat: number; ragSearch: number; voiceRealtime: number; totalCost: number }>
features: FeatureUsageBreakdown[]
userSummaries: Array<{
userId: number
email: string
name: string
tier: string
totalRequests: number
totalTokens: number
totalCost: number
}>
modelSummaries: Array<{
modelId: string
modelName: string
provider: string
totalRequests: number
totalTokens: number
totalCost: number
}>
userSummaries: Array<{ userId: number; email: string; name?: string; tier?: string; totalRequests: number; totalTokens: number; totalCost: number }>
modelSummaries: Array<{ modelId: string; modelName: string; provider?: string; totalRequests: number; totalTokens: number; totalCost: number }>
}
// ── Realistic Mock Fallbacks (D3RO Voice v0.2.1 / Phase 15.5 SSOT) ─────────
const MOCK_NODES: SystemNodeHealth[] = [
{
id: 'whisper-sidecar',
name: 'Faster-Whisper STT Engine',
category: 'stt',
status: 'operational',
latencyMs: 142,
uptimePercent: 99.92,
versionOrModel: 'large-v3-turbo (PyInstaller)',
vramOrMemory: '3.2 GB / 8.0 GB',
details: 'Dual-condition parallel buffer flush • 6x speedup active',
},
{
id: 'ollama-local',
name: 'Bundled Ollama Runtime',
category: 'llm',
status: 'operational',
latencyMs: 48,
uptimePercent: 99.85,
versionOrModel: 'Ollama v0.32.1 (gemma4:e4b)',
vramOrMemory: '4.6 GB / 8.0 GB',
details: 'Pruned slim 119MB runtime • NDJSON streaming active',
},
{
id: 'realtime-voice',
name: 'GPT-Realtime 2.1 Live Engine',
category: 'voice_realtime',
status: 'operational',
latencyMs: 185,
uptimePercent: 99.78,
versionOrModel: 'gpt-realtime-2.1 (Premium WebSocket)',
vramOrMemory: 'Cloud Managed',
details: 'Dual audio loopback • Local pipeline auto-fallback ready',
},
{
id: 'rag-sqlite',
name: 'Vector RAG & Embeddings',
category: 'rag_vector',
status: 'operational',
latencyMs: 18,
uptimePercent: 99.98,
versionOrModel: 'nomic-embed-text-v1.5',
vramOrMemory: '512 MB SQLite Vector',
details: 'Cosine similarity • 4,820 documents indexed',
},
{
id: 'diarization-pyannote',
name: 'Speaker Diarization Engine',
category: 'diarization',
status: 'operational',
latencyMs: 210,
uptimePercent: 99.64,
versionOrModel: 'Pyannote 3.1 + LLM Attribution',
vramOrMemory: '1.4 GB VRAM',
details: 'Multi-speaker voiceprint clustering (Phase 15.5)',
},
{
id: 'csharp-gateway',
name: 'C# .NET Core Gateway API',
category: 'backend_api',
status: 'operational',
latencyMs: 32,
uptimePercent: 99.99,
versionOrModel: '.NET 9.0 WebAPI',
vramOrMemory: '320 MB RAM',
details: 'Telemetry & token cost accounting active',
},
]
const MOCK_PIPELINES: PipelineStats = {
whisper: {
engine: 'faster-whisper (Python 3.11 sidecar)',
activeModel: 'large-v3-turbo (default)',
avgLatencyMs: 142,
speedupFactor: '6.2x vs base',
partialStreamingFps: 10,
totalTranscriptionsToday: 4890,
gpuVramUsage: '3.2 GB',
},
ollama: {
version: 'v0.32.1 (Bundled)',
loadedModels: ['gemma4:e4b', 'qwen2.5:7b', 'llama3:8b'],
activeContextLimit: 8192,
tokensPerSecond: 44.5,
vramAllocated: '4.6 GB',
activeSessions: 8,
},
realtimeVoice: {
backend: 'OpenAI GPT-Realtime 2.1 Audio WS',
activeStreams: 18,
streamUptime: 99.8,
localFallbackRate: '1.8%',
avgAudioRttMs: 185,
},
ragVector: {
embeddingModel: 'nomic-embed-text (SQLite Vector DB)',
indexedDocuments: 4820,
totalVectorChunks: 42900,
avgSearchLatencyMs: 18.4,
topHitRatePercent: 94.6,
},
meetingIntelligence: {
diarizationEngine: 'pyannote 3.1 + LLM speaker fallback',
speakerAccuracyPercent: 96.4,
activeMeetingSessions: 14,
templatesGeneratedToday: 86,
mindmapsExported: 42,
},
async function readJson<T>(path: string): Promise<T> {
const response = await fetchAdminBackend(path)
if (!response.ok) throw new Error(`Admin backend request failed (${response.status})`)
try {
return (await response.json()) as T
} catch {
throw new Error('Admin backend returned invalid JSON')
}
}
const MOCK_USERS: UserItem[] = [
{
id: 1,
uid: 'usr_d3ro_001',
email: 'admin@d3ro.voice',
name: 'D3RO System Architect',
role: 'super_admin',
tier: 'pro_plus',
createdAt: '2026-01-15T09:00:00Z',
lastLoginAt: '2026-08-19T02:45:00Z',
lastActiveDevice: 'Windows 11 x64 (Build 26100)',
isActive: true,
dailyUsage: { dictations: 42, dictationsMax: 9999, llmCalls: 128, llmCallsMax: 9999, ragQueries: 35, ragQueriesMax: 9999 },
},
{
id: 2,
uid: 'usr_d3ro_002',
email: 'sarah.kim@techcorp.io',
name: 'Sarah Kim',
role: 'admin',
tier: 'pro_plus',
createdAt: '2026-03-10T14:20:00Z',
lastLoginAt: '2026-08-19T01:30:00Z',
lastActiveDevice: 'macOS 15.4 arm64 (Apple M3 Max)',
isActive: true,
dailyUsage: { dictations: 184, dictationsMax: 9999, llmCalls: 86, llmCallsMax: 9999, ragQueries: 18, ragQueriesMax: 9999 },
},
{
id: 3,
uid: 'usr_d3ro_003',
email: 'minho.park@innovate.kr',
name: 'Minho Park',
role: 'user',
tier: 'pro_plus',
createdAt: '2026-04-02T11:15:00Z',
lastLoginAt: '2026-08-18T22:10:00Z',
lastActiveDevice: 'Windows 11 x64',
isActive: true,
dailyUsage: { dictations: 92, dictationsMax: 9999, llmCalls: 45, llmCallsMax: 9999, ragQueries: 12, ragQueriesMax: 9999 },
},
{
id: 4,
uid: 'usr_d3ro_004',
email: 'alex.chen@globalai.dev',
name: 'Alex Chen',
role: 'user',
tier: 'pro',
createdAt: '2026-05-18T16:40:00Z',
lastLoginAt: '2026-08-18T19:55:00Z',
lastActiveDevice: 'macOS 15.3 arm64 (Apple M2)',
isActive: true,
dailyUsage: { dictations: 64, dictationsMax: 9999, llmCalls: 142, llmCallsMax: 200, ragQueries: 5, ragQueriesMax: 10 },
},
{
id: 5,
uid: 'usr_d3ro_005',
email: 'jisoo.lee@creator.studio',
name: 'Jisoo Lee',
role: 'user',
tier: 'pro',
createdAt: '2026-06-01T08:12:00Z',
lastLoginAt: '2026-08-19T00:15:00Z',
lastActiveDevice: 'Windows 11 x64',
isActive: true,
dailyUsage: { dictations: 48, dictationsMax: 9999, llmCalls: 78, llmCallsMax: 200, ragQueries: 4, ragQueriesMax: 10 },
},
{
id: 6,
uid: 'usr_d3ro_006',
email: 'david.wilson@voicepod.com',
name: 'David Wilson',
role: 'manager',
tier: 'pro_plus',
createdAt: '2026-06-20T10:00:00Z',
lastLoginAt: '2026-08-18T15:22:00Z',
lastActiveDevice: 'macOS 15.4 arm64',
isActive: true,
dailyUsage: { dictations: 120, dictationsMax: 9999, llmCalls: 95, llmCallsMax: 9999, ragQueries: 28, ragQueriesMax: 9999 },
},
{
id: 7,
uid: 'usr_d3ro_007',
email: 'hyunjin.choi@startup.io',
name: 'Hyunjin Choi',
role: 'user',
tier: 'free',
createdAt: '2026-07-11T13:45:00Z',
lastLoginAt: '2026-08-19T02:10:00Z',
lastActiveDevice: 'Windows 10 x64',
isActive: true,
dailyUsage: { dictations: 18, dictationsMax: 20, llmCalls: 9, llmCallsMax: 10, ragQueries: 0, ragQueriesMax: 0 },
},
{
id: 8,
uid: 'usr_d3ro_008',
email: 'elena.rostova@designlab.eu',
name: 'Elena Rostova',
role: 'user',
tier: 'free',
createdAt: '2026-08-01T17:30:00Z',
lastLoginAt: '2026-08-17T12:00:00Z',
lastActiveDevice: 'macOS 15.2 arm64',
isActive: true,
dailyUsage: { dictations: 8, dictationsMax: 20, llmCalls: 3, llmCallsMax: 10, ragQueries: 0, ragQueriesMax: 0 },
},
]
const MOCK_ENDPOINTS: ModelEndpoint[] = [
{
id: 1,
modelId: 'whisper-large-v3-turbo',
modelName: 'Faster-Whisper Large-v3 Turbo (Local)',
provider: 'Local Sidecar',
endpointUrl: 'http://localhost:8971/stt/transcribe',
apiKey: 'internal-sidecar-token',
costPer1kPromptTokens: 0.000000,
costPer1kCompletionTokens: 0.000000,
latencyMs: 142,
isActive: true,
isDefault: true,
createdAt: '2026-01-15T09:00:00Z',
},
{
id: 2,
modelId: 'ollama-gemma4-e4b',
modelName: 'Ollama Gemma-4 E4B (Bundled Local)',
provider: 'Ollama Local',
endpointUrl: 'http://localhost:11434/api/generate',
apiKey: '',
costPer1kPromptTokens: 0.000000,
costPer1kCompletionTokens: 0.000000,
latencyMs: 48,
isActive: true,
isDefault: true,
createdAt: '2026-02-01T10:00:00Z',
},
{
id: 3,
modelId: 'gpt-realtime-2.1',
modelName: 'OpenAI GPT-Realtime 2.1 (Live Voice)',
provider: 'OpenAI',
endpointUrl: 'wss://api.openai.com/v1/realtime',
apiKey: 'sk-proj-rt-••••••••',
costPer1kPromptTokens: 0.005000,
costPer1kCompletionTokens: 0.020000,
latencyMs: 185,
isActive: true,
isDefault: false,
createdAt: '2026-05-10T12:00:00Z',
},
{
id: 4,
modelId: 'gpt-4o-mini',
modelName: 'GPT-4o Mini (Cloud Synthesis & Meeting)',
provider: 'OpenAI',
endpointUrl: 'https://api.openai.com/v1/chat/completions',
apiKey: 'sk-proj-••••••••',
costPer1kPromptTokens: 0.000150,
costPer1kCompletionTokens: 0.000600,
latencyMs: 240,
isActive: true,
isDefault: false,
createdAt: '2026-04-12T08:00:00Z',
},
{
id: 5,
modelId: 'claude-3-5-sonnet',
modelName: 'Claude 3.5 Sonnet (Complex Action Planning)',
provider: 'Anthropic',
endpointUrl: 'https://api.anthropic.com/v1/messages',
apiKey: 'sk-ant-••••••••',
costPer1kPromptTokens: 0.003000,
costPer1kCompletionTokens: 0.015000,
latencyMs: 380,
isActive: true,
isDefault: false,
createdAt: '2026-06-01T14:00:00Z',
},
{
id: 6,
modelId: 'nomic-embed-text',
modelName: 'Nomic Embed Text v1.5 (RAG Embeddings)',
provider: 'Ollama Local',
endpointUrl: 'http://localhost:11434/api/embeddings',
apiKey: '',
costPer1kPromptTokens: 0.000000,
costPer1kCompletionTokens: 0.000000,
latencyMs: 18,
isActive: true,
isDefault: true,
createdAt: '2026-03-20T11:00:00Z',
},
]
const MOCK_USAGE_REPORT: UsageReport = {
totalRequests: 142890,
totalPromptTokens: 28450120,
totalCompletionTokens: 14210980,
totalCost: 24.8912,
timeline: [
{ date: '2026-08-13', dictations: 1420, meetingSummaries: 38, aiChat: 310, ragSearch: 180, voiceRealtime: 42, totalCost: 2.841 },
{ date: '2026-08-14', dictations: 1680, meetingSummaries: 45, aiChat: 345, ragSearch: 210, voiceRealtime: 58, totalCost: 3.290 },
{ date: '2026-08-15', dictations: 1890, meetingSummaries: 52, aiChat: 410, ragSearch: 260, voiceRealtime: 64, totalCost: 3.840 },
{ date: '2026-08-16', dictations: 1250, meetingSummaries: 28, aiChat: 280, ragSearch: 140, voiceRealtime: 35, totalCost: 2.120 },
{ date: '2026-08-17', dictations: 1120, meetingSummaries: 22, aiChat: 240, ragSearch: 110, voiceRealtime: 30, totalCost: 1.940 },
{ date: '2026-08-18', dictations: 2140, meetingSummaries: 74, aiChat: 520, ragSearch: 380, voiceRealtime: 88, totalCost: 5.120 },
{ date: '2026-08-19', dictations: 2480, meetingSummaries: 86, aiChat: 610, ragSearch: 420, voiceRealtime: 104, totalCost: 5.740 },
],
features: [
{ featureId: 'dictation', featureName: 'Realtime Dictation (Whisper Turbo)', category: 'STT', callCount: 88420, tokensUsed: 12400000, totalCost: 0.00, avgLatencyMs: 142 },
{ featureId: 'meeting_mode', featureName: 'Meeting Mode + Multi-Doc Generator', category: 'Intelligence', callCount: 12840, tokensUsed: 8920000, totalCost: 6.42, avgLatencyMs: 680 },
{ featureId: 'speaker_diarization', featureName: 'Speaker Diarization (pyannote + LLM)', category: 'Audio', callCount: 14200, tokensUsed: 4200000, totalCost: 2.10, avgLatencyMs: 210 },
{ featureId: 'voice_realtime', featureName: 'Live Voice Conversation (Realtime)', category: 'Voice Mode', callCount: 6890, tokensUsed: 5410000, totalCost: 11.24, avgLatencyMs: 185 },
{ featureId: 'rag_search', featureName: 'Vector RAG Document Semantic Search', category: 'Knowledge', callCount: 12540, tokensUsed: 1240000, totalCost: 0.89, avgLatencyMs: 18 },
{ featureId: 'auto_polish', featureName: 'Auto Polish & Grammar Refinement', category: 'LLM', callCount: 8000, tokensUsed: 1091100, totalCost: 4.24, avgLatencyMs: 48 },
],
userSummaries: [
{ userId: 2, email: 'sarah.kim@techcorp.io', name: 'Sarah Kim', tier: 'pro_plus', totalRequests: 18420, totalTokens: 6420000, totalCost: 5.842 },
{ userId: 1, email: 'admin@d3ro.voice', name: 'D3RO Admin', tier: 'pro_plus', totalRequests: 14200, totalTokens: 4890000, totalCost: 4.120 },
{ userId: 6, email: 'david.wilson@voicepod.com', name: 'David Wilson', tier: 'pro_plus', totalRequests: 12400, totalTokens: 3820000, totalCost: 3.450 },
{ userId: 3, email: 'minho.park@innovate.kr', name: 'Minho Park', tier: 'pro_plus', totalRequests: 9840, totalTokens: 2940000, totalCost: 2.640 },
{ userId: 4, email: 'alex.chen@globalai.dev', name: 'Alex Chen', tier: 'pro', totalRequests: 8200, totalTokens: 2410000, totalCost: 1.820 },
{ userId: 5, email: 'jisoo.lee@creator.studio', name: 'Jisoo Lee', tier: 'pro', totalRequests: 6400, totalTokens: 1890000, totalCost: 1.420 },
],
modelSummaries: [
{ modelId: 'whisper-large-v3-turbo', modelName: 'Faster-Whisper Large-v3 Turbo', provider: 'Local Sidecar', totalRequests: 88420, totalTokens: 12400000, totalCost: 0.00 },
{ modelId: 'ollama-gemma4-e4b', modelName: 'Ollama Gemma-4 E4B', provider: 'Ollama Local', totalRequests: 32400, totalTokens: 14820000, totalCost: 0.00 },
{ modelId: 'gpt-realtime-2.1', modelName: 'OpenAI GPT-Realtime 2.1', provider: 'OpenAI', totalRequests: 6890, totalTokens: 5410000, totalCost: 11.24 },
{ modelId: 'gpt-4o-mini', modelName: 'GPT-4o Mini', provider: 'OpenAI', totalRequests: 12840, totalTokens: 8920000, totalCost: 6.42 },
{ modelId: 'claude-3-5-sonnet', modelName: 'Claude 3.5 Sonnet', provider: 'Anthropic', totalRequests: 2340, totalTokens: 1111100, totalCost: 7.23 },
],
function normalizeRole(value: unknown): UserItem['role'] {
const normalized = typeof value === 'string' ? value.replace(/[_-]/g, '').toLowerCase() : ''
if (normalized === 'superadmin') return 'super_admin'
if (normalized === 'admin') return 'admin'
if (normalized === 'manager') return 'manager'
return 'user'
}
const MOCK_FEATURE_BREAKDOWN: FeatureUsageBreakdown[] = [
{ featureId: 'dictation', featureName: 'Realtime Dictation (Whisper Turbo)', category: 'STT', totalCalls: 88420, percentage: 61.8, tokensUsed: 12400000, totalCost: 0.00, estimatedCostUsd: 0.00, avgLatencyMs: 142 },
{ featureId: 'meeting_mode', featureName: 'Meeting Mode + Multi-Doc Generator', category: 'Intelligence', totalCalls: 12840, percentage: 9.0, tokensUsed: 8920000, totalCost: 6.42, estimatedCostUsd: 6.42, avgLatencyMs: 680 },
{ featureId: 'speaker_diarization', featureName: 'Speaker Diarization (Pyannote + LLM)', category: 'Audio', totalCalls: 14200, percentage: 9.9, tokensUsed: 4200000, totalCost: 2.10, estimatedCostUsd: 2.10, avgLatencyMs: 210 },
{ featureId: 'voice_realtime', featureName: 'Live Voice Conversation (Realtime)', category: 'Voice Mode', totalCalls: 6890, percentage: 4.8, tokensUsed: 5410000, totalCost: 11.24, estimatedCostUsd: 11.24, avgLatencyMs: 185 },
{ featureId: 'rag_search', featureName: 'Vector RAG Document Semantic Search', category: 'Knowledge', totalCalls: 12540, percentage: 8.8, tokensUsed: 1240000, totalCost: 0.89, estimatedCostUsd: 0.89, avgLatencyMs: 18 },
{ featureId: 'auto_polish', featureName: 'Auto Polish & Grammar Refinement', category: 'LLM', totalCalls: 8000, percentage: 5.7, tokensUsed: 1091100, totalCost: 4.24, estimatedCostUsd: 4.24, avgLatencyMs: 48 },
]
// ── API Fetch Functions ───────────────────────────────────────────────────
export async function fetchServerStats(): Promise<ServerStats> {
try {
const res = await fetch(`${API_BASE}/api/admin/stats`, { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
return {
totalUsers: data.totalUsers ?? 4580,
activeUsersToday: data.activeUsersToday ?? 1240,
totalRequests: data.totalRequests ?? 142890,
totalCost: data.totalCost ?? 24.8912,
serverUptimeSeconds: data.serverUptimeSeconds ?? 864200,
errorCount: data.errorCount ?? 0,
arrUsd: 231480,
mrrUsd: 19290,
tierDistribution: { free: 3420, pro: 842, pro_plus: 318 },
nodes: MOCK_NODES,
pipelines: MOCK_PIPELINES,
featureBreakdown: MOCK_FEATURE_BREAKDOWN,
recentErrors: data.recentErrors ?? [],
}
} catch {
return {
totalUsers: 4580,
activeUsersToday: 1240,
totalRequests: 142890,
totalCost: 24.8912,
serverUptimeSeconds: 864200,
errorCount: 0,
arrUsd: 231480,
mrrUsd: 19290,
tierDistribution: { free: 3420, pro: 842, pro_plus: 318 },
nodes: MOCK_NODES,
pipelines: MOCK_PIPELINES,
featureBreakdown: MOCK_FEATURE_BREAKDOWN,
recentErrors: [],
}
const data = await readJson<Partial<ServerStats>>('/stats')
if (typeof data.totalUsers !== 'number' || typeof data.activeUsersToday !== 'number' || typeof data.totalRequests !== 'number' || typeof data.totalCost !== 'number' || typeof data.serverUptimeSeconds !== 'number' || typeof data.errorCount !== 'number') {
throw new Error('Admin stats response is incomplete')
}
return {
totalUsers: data.totalUsers,
activeUsersToday: data.activeUsersToday,
totalRequests: data.totalRequests,
totalCost: data.totalCost,
serverUptimeSeconds: data.serverUptimeSeconds,
errorCount: data.errorCount,
arrUsd: typeof data.arrUsd === 'number' ? data.arrUsd : null,
mrrUsd: typeof data.mrrUsd === 'number' ? data.mrrUsd : null,
tierDistribution: data.tierDistribution ?? null,
nodes: Array.isArray(data.nodes) ? data.nodes : [],
pipelines: data.pipelines ?? null,
featureBreakdown: Array.isArray(data.featureBreakdown) ? data.featureBreakdown : [],
recentErrors: Array.isArray(data.recentErrors) ? data.recentErrors : []
}
}
export async function fetchUsers(): Promise<UserItem[]> {
try {
const res = await fetch(`${API_BASE}/api/admin/users`, { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
return Array.isArray(data) && data.length > 0 ? data : MOCK_USERS
} catch {
return MOCK_USERS
}
const data = await readJson<Array<Record<string, unknown>>>('/users')
if (!Array.isArray(data)) throw new Error('Admin users response is invalid')
return data.map((item) => {
if (typeof item.id !== 'number' || typeof item.email !== 'string' || typeof item.createdAt !== 'string') {
throw new Error('Admin users response contains an invalid row')
}
return {
id: String(item.id),
uid: String(item.id),
email: item.email,
name: item.email,
role: normalizeRole(item.role),
tier: null,
createdAt: item.createdAt,
lastLoginAt: typeof item.lastLoginAt === 'string' ? item.lastLoginAt : null,
lastActiveDevice: null,
isActive: item.isActive === true,
dailyUsage: null
}
})
}
export async function fetchModelEndpoints(): Promise<ModelEndpoint[]> {
try {
const res = await fetch(`${API_BASE}/api/admin/endpoints`, { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
return Array.isArray(data) && data.length > 0 ? data : MOCK_ENDPOINTS
} catch {
return MOCK_ENDPOINTS
}
}
export async function createModelEndpoint(dto: {
modelId: string
modelName: string
provider: string
endpointUrl: string
apiKey: string
costPer1kPromptTokens: number
costPer1kCompletionTokens: number
}): Promise<ModelEndpoint> {
const res = await fetch(`${API_BASE}/api/admin/endpoints`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dto),
})
if (!res.ok) throw new Error(`Failed to create model endpoint: ${res.statusText}`)
return res.json()
}
export async function deleteModelEndpoint(id: number): Promise<boolean> {
const res = await fetch(`${API_BASE}/api/admin/endpoints/${id}`, { method: 'DELETE' })
return res.ok
}
// ── STT Provider API Fetch Functions ───────────────────────────────────────
export const MOCK_STT_ENDPOINTS: SttProviderEndpoint[] = [
{
id: 1,
name: 'Groq Whisper LPU Turbo (Ultra Fast)',
providerType: 'groq',
endpointUrl: 'https://api.groq.com/openai/v1/audio/transcriptions',
apiKey: '••••••••',
modelId: 'whisper-large-v3-turbo',
method: 'multipart',
language: 'ko',
prompt: null,
temperature: 0.0,
costPerMinute: 0.0005,
costPerSecond: 0.000008,
isDefault: true,
isActive: true,
fallbackPriority: 1,
extraHeadersJson: null,
latencyMs: 140,
createdAt: '2026-01-15T09:00:00Z',
updatedAt: null,
},
{
id: 2,
name: 'OpenAI Whisper Official',
providerType: 'openai',
endpointUrl: 'https://api.openai.com/v1/audio/transcriptions',
apiKey: '••••••••',
modelId: 'whisper-1',
method: 'multipart',
language: 'ko',
prompt: null,
temperature: 0.0,
costPerMinute: 0.006,
costPerSecond: 0.0001,
isDefault: false,
isActive: true,
fallbackPriority: 2,
extraHeadersJson: null,
latencyMs: 380,
createdAt: '2026-02-01T10:00:00Z',
updatedAt: null,
},
{
id: 3,
name: 'Deepgram Nova-3 Industry Standard',
providerType: 'deepgram',
endpointUrl: 'https://api.deepgram.com/v1/listen',
apiKey: '••••••••',
modelId: 'nova-3',
method: 'binary-stream',
language: 'ko',
prompt: null,
temperature: 0.0,
costPerMinute: 0.0043,
costPerSecond: 0.000072,
isDefault: false,
isActive: true,
fallbackPriority: 3,
extraHeadersJson: null,
latencyMs: 195,
createdAt: '2026-03-10T12:00:00Z',
updatedAt: null,
},
{
id: 4,
name: 'Google Gemini 2.0 Flash / Cloud STT',
providerType: 'google',
endpointUrl: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent',
apiKey: '••••••••',
modelId: 'gemini-2.0-flash',
method: 'json-base64',
language: 'ko',
prompt: null,
temperature: 0.0,
costPerMinute: 0.001,
costPerSecond: 0.000017,
isDefault: false,
isActive: true,
fallbackPriority: 4,
extraHeadersJson: null,
latencyMs: 260,
createdAt: '2026-04-12T08:00:00Z',
updatedAt: null,
},
{
id: 5,
name: 'AssemblyAI Universal-2',
providerType: 'assemblyai',
endpointUrl: 'https://api.assemblyai.com/v2/transcript',
apiKey: '••••••••',
modelId: 'best',
method: 'multipart',
language: 'ko',
prompt: null,
temperature: 0.0,
costPerMinute: 0.0025,
costPerSecond: 0.000042,
isDefault: false,
isActive: true,
fallbackPriority: 5,
extraHeadersJson: null,
latencyMs: 520,
createdAt: '2026-05-18T14:00:00Z',
updatedAt: null,
},
{
id: 6,
name: 'Local Sidecar (Offline Faster-Whisper)',
providerType: 'local-sidecar',
endpointUrl: 'http://localhost:8971/stt/transcribe',
apiKey: '',
modelId: 'whisper-large-v3-turbo',
method: 'multipart',
language: 'ko',
prompt: null,
temperature: 0.0,
costPerMinute: 0.0,
costPerSecond: 0.0,
isDefault: false,
isActive: true,
fallbackPriority: 6,
extraHeadersJson: null,
latencyMs: 142,
createdAt: '2026-01-15T09:00:00Z',
updatedAt: null,
},
]
export const MOCK_STT_USAGE_REPORT: SttUsageReport = {
totalTranscriptions: 88420,
totalAudioMinutes: 14820.5,
totalCost: 7.41,
avgLatencyMs: 165.4,
providerSummaries: [
{ provider: 'groq', modelId: 'whisper-large-v3-turbo', totalRequests: 74200, totalAudioMinutes: 12400.0, totalCost: 6.20, avgLatencyMs: 142.0 },
{ provider: 'openai', modelId: 'whisper-1', totalRequests: 8400, totalAudioMinutes: 1420.5, totalCost: 8.52, avgLatencyMs: 380.0 },
{ provider: 'deepgram', modelId: 'nova-3', totalRequests: 5820, totalAudioMinutes: 1000.0, totalCost: 4.30, avgLatencyMs: 195.0 },
],
userSummaries: [
{ userId: 1, email: 'admin@d3ro.voice', totalRequests: 14200, totalAudioMinutes: 2480.0, totalCost: 1.24 },
{ userId: 2, email: 'sarah.kim@techcorp.io', totalRequests: 18420, totalAudioMinutes: 3200.0, totalCost: 1.60 },
],
const data = await readJson<ModelEndpoint[]>('/endpoints')
if (!Array.isArray(data)) throw new Error('Model endpoints response is invalid')
return data
}
export async function fetchSttEndpoints(): Promise<SttProviderEndpoint[]> {
try {
const res = await fetch(`${API_BASE}/api/admin/stt-endpoints`, { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
return Array.isArray(data) && data.length > 0 ? data : MOCK_STT_ENDPOINTS
} catch {
return MOCK_STT_ENDPOINTS
}
}
export async function createSttEndpoint(dto: CreateSttEndpointDto): Promise<SttProviderEndpoint> {
const res = await fetch(`${API_BASE}/api/admin/stt-endpoints`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dto),
})
if (!res.ok) throw new Error(`Failed to create STT endpoint: ${res.statusText}`)
return res.json()
}
export async function updateSttEndpoint(id: number, dto: UpdateSttEndpointDto): Promise<SttProviderEndpoint> {
const res = await fetch(`${API_BASE}/api/admin/stt-endpoints/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dto),
})
if (!res.ok) throw new Error(`Failed to update STT endpoint: ${res.statusText}`)
return res.json()
}
export async function deleteSttEndpoint(id: number): Promise<boolean> {
const res = await fetch(`${API_BASE}/api/admin/stt-endpoints/${id}`, { method: 'DELETE' })
return res.ok
}
export async function setDefaultSttEndpoint(id: number): Promise<boolean> {
const res = await fetch(`${API_BASE}/api/admin/stt-endpoints/${id}/set-default`, { method: 'POST' })
return res.ok
}
export async function testSttEndpoint(id: number, apiKey?: string, endpointUrl?: string): Promise<SttTestResult> {
try {
let url = `${API_BASE}/api/admin/stt-endpoints/${id}/test`
if (id === 0 && endpointUrl) {
url = `${API_BASE}/api/admin/stt-endpoints/test-direct?endpointUrl=${encodeURIComponent(endpointUrl)}&apiKey=${encodeURIComponent(apiKey || '')}`
}
const res = await fetch(url, { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json()
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : 'Connection test failed',
latencyMs: 0,
transcriptPreview: null,
provider: null,
modelId: null,
}
}
const data = await readJson<SttProviderEndpoint[]>('/stt-endpoints')
if (!Array.isArray(data)) throw new Error('STT endpoints response is invalid')
return data
}
export async function fetchSttUsageReport(): Promise<SttUsageReport> {
try {
const res = await fetch(`${API_BASE}/api/admin/stt-usage`, { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json()
} catch {
return MOCK_STT_USAGE_REPORT
}
return readJson<SttUsageReport>('/stt-usage')
}
export async function fetchUsageReport(): Promise<UsageReport> {
try {
const res = await fetch(`${API_BASE}/api/admin/usage`, { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
return {
totalRequests: data.totalRequests ?? MOCK_USAGE_REPORT.totalRequests,
totalPromptTokens: data.totalPromptTokens ?? MOCK_USAGE_REPORT.totalPromptTokens,
totalCompletionTokens: data.totalCompletionTokens ?? MOCK_USAGE_REPORT.totalCompletionTokens,
totalCost: data.totalCost ?? MOCK_USAGE_REPORT.totalCost,
timeline: MOCK_USAGE_REPORT.timeline,
features: MOCK_USAGE_REPORT.features,
userSummaries: data.userSummaries && data.userSummaries.length > 0 ? data.userSummaries : MOCK_USAGE_REPORT.userSummaries,
modelSummaries: data.modelSummaries && data.modelSummaries.length > 0 ? data.modelSummaries : MOCK_USAGE_REPORT.modelSummaries,
}
} catch {
return MOCK_USAGE_REPORT
}
const data = await readJson<Omit<UsageReport, 'timeline' | 'features'> & Partial<Pick<UsageReport, 'timeline' | 'features'>>>('/usage')
return { ...data, timeline: Array.isArray(data.timeline) ? data.timeline : [], features: Array.isArray(data.features) ? data.features : [] }
}

View file

@ -0,0 +1,35 @@
import 'server-only'
const SENSITIVE_KEYS = new Set([
'api_key',
'password',
'payload',
'payload_digest',
'payple_payer_id',
'payple_pay_oid',
'provider_event_id',
'provider_resource_id',
'raw_payload',
'secret',
'token'
])
function sanitizeValue(value: unknown, depth: number): unknown {
if (depth > 8) return '[REDACTED]'
if (Array.isArray(value)) return value.map((item) => sanitizeValue(item, depth + 1))
if (!value || typeof value !== 'object') return value
const sanitized: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
if (SENSITIVE_KEYS.has(key.toLowerCase())) continue
sanitized[key] = sanitizeValue(item, depth + 1)
}
return sanitized
}
export function sanitizeAuditSnapshot(value: unknown): Record<string, unknown> | null {
const sanitized = sanitizeValue(value, 0)
return sanitized && typeof sanitized === 'object' && !Array.isArray(sanitized)
? sanitized as Record<string, unknown>
: null
}

View file

@ -0,0 +1,36 @@
'use client'
import type { CreateSttEndpointDto, ModelEndpoint, SttProviderEndpoint, SttTestResult, UpdateSttEndpointDto } from './api-server'
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`/api/admin/backend${path}`, {
...init,
headers: { Accept: 'application/json', ...(init.body ? { 'Content-Type': 'application/json' } : {}), ...(init.headers ?? {}) },
cache: 'no-store'
})
const payload = await response.json().catch(() => null) as { error?: string; message?: string } | null
if (!response.ok) throw new Error(payload?.error ?? payload?.message ?? `Admin request failed (${response.status})`)
return payload as T
}
export const fetchModelEndpointsClient = (): Promise<ModelEndpoint[]> => request('/endpoints')
export const fetchSttEndpointsClient = (): Promise<SttProviderEndpoint[]> => request('/stt-endpoints')
function mutationHeaders(): Record<string, string> {
return { 'Idempotency-Key': crypto.randomUUID() }
}
export const createModelEndpointClient = (dto: { modelId: string; modelName: string; provider: string; endpointUrl: string; apiKey: string; costPer1kPromptTokens: number; costPer1kCompletionTokens: number; memo: string }): Promise<ModelEndpoint> => request('/endpoints', { method: 'POST', body: JSON.stringify(dto), headers: mutationHeaders() })
export const deleteModelEndpointClient = (id: number, memo: string): Promise<{ message: string }> => request(`/endpoints/${id}`, { method: 'DELETE', body: JSON.stringify({ memo }), headers: mutationHeaders() })
export const createSttEndpointClient = (dto: CreateSttEndpointDto): Promise<SttProviderEndpoint> => request('/stt-endpoints', { method: 'POST', body: JSON.stringify(dto), headers: mutationHeaders() })
export const updateSttEndpointClient = (id: number, dto: UpdateSttEndpointDto): Promise<SttProviderEndpoint> => request(`/stt-endpoints/${id}`, { method: 'PUT', body: JSON.stringify(dto), headers: mutationHeaders() })
export const deleteSttEndpointClient = (id: number, memo: string): Promise<{ message: string }> => request(`/stt-endpoints/${id}`, { method: 'DELETE', body: JSON.stringify({ memo }), headers: mutationHeaders() })
export const setDefaultSttEndpointClient = (id: number, memo: string): Promise<{ success: boolean }> => request(`/stt-endpoints/${id}/set-default`, { method: 'POST', body: JSON.stringify({ memo }), headers: mutationHeaders() })
export const testSttEndpointClient = (id: number, apiKey?: string, endpointUrl?: string): Promise<SttTestResult> => {
if (id === 0 && endpointUrl) {
return request('/stt-endpoints/test-direct', {
method: 'POST',
body: JSON.stringify({ endpointUrl, apiKey: apiKey ?? null })
})
}
return request(`/stt-endpoints/${id}/test`, { method: 'POST' })
}

View file

@ -0,0 +1,112 @@
import 'server-only'
import { cookies } from 'next/headers'
import { verifySession } from './security'
import type { AdminRole, AdminSession } from './admin-session'
export class AdminBackendError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message)
}
}
const ROLE_LEVEL: Record<AdminRole, number> = {
manager: 1,
admin: 2,
super_admin: 3
}
function normalizeRole(value: unknown): AdminRole | null {
if (typeof value !== 'string') return null
const normalized = value.replace(/[_-]/g, '').toLowerCase()
if (normalized === 'manager') return 'manager'
if (normalized === 'admin') return 'admin'
if (normalized === 'superadmin') return 'super_admin'
return null
}
export function requireApiServerOrigin(): string {
const configured = process.env.API_SERVER_URL?.trim()
if (!configured) throw new AdminBackendError('admin_backend_unavailable', 503)
let parsed: URL
try {
parsed = new URL(configured)
} catch {
throw new AdminBackendError('admin_backend_unavailable', 503)
}
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
throw new AdminBackendError('admin_backend_unavailable', 503)
}
if (parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) {
throw new AdminBackendError('admin_backend_unavailable', 503)
}
return parsed.origin
}
export async function requireVerifiedBackendSession(
minimumRole: AdminRole = 'manager'
): Promise<AdminSession> {
const cookieStore = await cookies()
const signedSession = cookieStore.get('d3ro_admin_session')?.value
const session = signedSession ? verifySession(signedSession) : null
if (!session) throw new AdminBackendError('admin_session_invalid', 401)
if (ROLE_LEVEL[session.role] < ROLE_LEVEL[minimumRole]) {
throw new AdminBackendError('admin_forbidden', 403)
}
let response: Response
try {
response = await fetch(`${requireApiServerOrigin()}/api/auth/me`, {
headers: { Authorization: `Bearer ${session.token}` },
cache: 'no-store',
signal: AbortSignal.timeout(7_000)
})
} catch {
throw new AdminBackendError('admin_backend_unavailable', 503)
}
if (response.status === 401 || response.status === 403) {
throw new AdminBackendError('admin_session_invalid', 401)
}
if (!response.ok) throw new AdminBackendError('admin_backend_unavailable', 503)
let identity: Record<string, unknown>
try {
identity = (await response.json()) as Record<string, unknown>
} catch {
throw new AdminBackendError('admin_backend_invalid_response', 502)
}
const email = typeof identity.email === 'string' ? identity.email.trim().toLowerCase() : ''
const role = normalizeRole(identity.role)
if (email !== session.email || role !== session.role) {
throw new AdminBackendError('admin_session_mismatch', 401)
}
return session
}
export async function fetchAdminBackend(
path: string,
init: RequestInit = {},
minimumRole: AdminRole = 'manager'
): Promise<Response> {
const session = await requireVerifiedBackendSession(minimumRole)
return fetch(`${requireApiServerOrigin()}/api/admin${path}`, {
...init,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${session.token}`,
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...(init.headers ?? {})
},
cache: 'no-store',
signal: init.signal ?? AbortSignal.timeout(10_000)
})
}

View file

@ -83,7 +83,7 @@ export const tableSx = {
'& th': {
px: 2,
pb: 1.5,
fontWeight: 600,
fontWeight: 500,
fontSize: '11px',
textTransform: 'uppercase' as const,
letterSpacing: '0.08em',
@ -125,7 +125,7 @@ export const filterBtnSx = (active: boolean) => ({
borderRadius: '999px',
fontFamily: FONT_SANS,
fontSize: '11px',
fontWeight: 600,
fontWeight: 500,
letterSpacing: '0.04em',
textTransform: 'none' as const,
cursor: 'pointer',
@ -166,7 +166,7 @@ export function statusBadgeSx(variant: 'green' | 'red' | 'orange' | 'blue' | 'pu
borderRadius: '999px',
fontSize: '11px',
fontFamily: FONT_SANS,
fontWeight: 600,
fontWeight: 500,
letterSpacing: '0.04em',
bgcolor: c.bg,
color: c.fg,
@ -182,7 +182,7 @@ export const primaryButtonSx = {
color: '#ffffff',
fontFamily: FONT_SANS,
fontSize: '13px',
fontWeight: 600,
fontWeight: 500,
borderRadius: '10px',
px: 2.5,
py: 1,

View file

@ -0,0 +1,40 @@
import {
decodeAdminSessionPayload,
decodeBase64Url,
requireAdminSessionSecret,
type AdminSession
} from './admin-session'
const encoder = new TextEncoder()
export async function verifyEdgeSession(token: string): Promise<AdminSession | null> {
try {
const parts = token.split('.')
if (parts.length !== 2) return null
const [encodedPayload, encodedSignature] = parts
const signature = decodeBase64Url(encodedSignature)
if (!signature || signature.byteLength !== 32) return null
const signatureBuffer = new ArrayBuffer(signature.byteLength)
new Uint8Array(signatureBuffer).set(signature)
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(requireAdminSessionSecret()),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
)
const verified = await crypto.subtle.verify(
'HMAC',
key,
signatureBuffer,
encoder.encode(encodedPayload)
)
if (!verified) return null
return decodeAdminSessionPayload(encodedPayload)
} catch {
return null
}
}

View file

@ -0,0 +1,148 @@
import 'server-only'
export type ReleaseAssetPlatform = 'windows' | 'macos' | 'android' | 'linux' | 'feed' | 'other'
export interface ReleaseAsset {
id: number
name: string
platform: ReleaseAssetPlatform
sizeBytes: number
downloadCount: number
browserDownloadUrl: string
createdAt: string
sha256: string | null
}
export interface ReleaseEntry {
id: number
tagName: string
name: string
publishedAt: string
isPrerelease: boolean
isDraft: boolean
htmlUrl: string
assets: ReleaseAsset[]
downloadCount: number
}
export interface ReleaseHub {
repoHtmlUrl: string
releases: ReleaseEntry[]
latestStable: ReleaseEntry | null
totalDownloads: number
prereleaseCount: number
}
const DEFAULT_REPO_URL = 'https://git.chanpaca.net/yunchan/d3ro-voice'
export function releaseRepoHtmlUrl(): string {
const configured = process.env.RELEASE_REPO_URL?.trim()
const value = configured || DEFAULT_REPO_URL
const parsed = new URL(value)
if (parsed.protocol !== 'https:') throw new Error('Release repository URL must use HTTPS')
const segments = parsed.pathname.split('/').filter(Boolean)
if (segments.length !== 2) throw new Error('Release repository URL must point to owner/repo')
return `${parsed.origin}/${segments[0]}/${segments[1]}`
}
function releaseApiUrl(): string {
const html = new URL(releaseRepoHtmlUrl())
return `${html.origin}/api/v1/repos${html.pathname}/releases`
}
function inferPlatform(assetName: string): ReleaseAssetPlatform {
const name = assetName.toLowerCase()
if (name.endsWith('.yml') || name.endsWith('.yaml')) return 'feed'
if (name.endsWith('.exe') || name.endsWith('.exe.blockmap') || name.endsWith('.msi') || name.endsWith('.nupkg')) {
return 'windows'
}
if (name.endsWith('.dmg') || name.includes('mac') || name.includes('darwin')) return 'macos'
if (name.endsWith('.apk') || name.endsWith('.aab')) return 'android'
if (name.endsWith('.appimage') || name.endsWith('.deb') || name.endsWith('.rpm')) return 'linux'
return 'other'
}
/** 릴리스 본문에서 `파일명`: `64자 hex` 형태의 SHA-256 표기를 수집한다. */
function parseChecksums(body: unknown): Map<string, string> {
const checksums = new Map<string, string>()
if (typeof body !== 'string' || !body) return checksums
const pattern = /`([^`\n]+)`\s*[:]\s*`([0-9a-fA-F]{64})`/g
for (const match of body.matchAll(pattern)) {
checksums.set(match[1].trim(), match[2].toLowerCase())
}
return checksums
}
function parseAsset(value: Record<string, unknown>, checksums: Map<string, string>): ReleaseAsset | null {
const id = typeof value.id === 'number' ? value.id : null
const name = typeof value.name === 'string' ? value.name : ''
const downloadUrl = typeof value.browser_download_url === 'string' ? value.browser_download_url : ''
if (id === null || !name || !downloadUrl) return null
return {
id,
name,
platform: inferPlatform(name),
sizeBytes: typeof value.size === 'number' ? value.size : 0,
downloadCount: typeof value.download_count === 'number' ? value.download_count : 0,
browserDownloadUrl: downloadUrl,
createdAt: typeof value.created_at === 'string' ? value.created_at : '',
sha256: checksums.get(name) ?? null
}
}
function parseRelease(value: unknown): ReleaseEntry | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const record = value as Record<string, unknown>
const id = typeof record.id === 'number' ? record.id : null
const tagName = typeof record.tag_name === 'string' ? record.tag_name : ''
const htmlUrl = typeof record.html_url === 'string' ? record.html_url : ''
if (id === null || !tagName || !htmlUrl) return null
const checksums = parseChecksums(record.body)
const rawAssets = Array.isArray(record.assets) ? record.assets : []
const assets = rawAssets
.filter((asset): asset is Record<string, unknown> => !!asset && typeof asset === 'object')
.map((asset) => parseAsset(asset, checksums))
.filter((asset): asset is ReleaseAsset => asset !== null)
return {
id,
tagName,
name: typeof record.name === 'string' && record.name.trim() ? record.name : tagName,
publishedAt: typeof record.published_at === 'string' ? record.published_at : '',
isPrerelease: record.prerelease === true,
isDraft: record.draft === true,
htmlUrl,
assets,
downloadCount: assets.reduce((sum, asset) => sum + asset.downloadCount, 0)
}
}
export async function fetchReleaseHub(): Promise<ReleaseHub> {
const response = await fetch(`${releaseApiUrl()}?limit=30`, {
headers: { Accept: 'application/json' },
cache: 'no-store',
signal: AbortSignal.timeout(8_000)
})
if (!response.ok) throw new Error(`Forgejo release feed request failed (${response.status})`)
let payload: unknown
try {
payload = await response.json()
} catch {
throw new Error('Forgejo release feed returned invalid JSON')
}
if (!Array.isArray(payload)) throw new Error('Forgejo release feed response is invalid')
const releases = payload
.map(parseRelease)
.filter((release): release is ReleaseEntry => release !== null && !release.isDraft)
return {
repoHtmlUrl: releaseRepoHtmlUrl(),
releases,
latestStable: releases.find((release) => !release.isPrerelease) ?? releases[0] ?? null,
totalDownloads: releases.reduce((sum, release) => sum + release.downloadCount, 0),
prereleaseCount: releases.filter((release) => release.isPrerelease).length
}
}

View file

@ -1,9 +1,14 @@
// apps/admin/src/lib/security.ts
// D3RO Voice — Military-Grade Admin Security & Rate-Limiting Engine
import crypto from 'crypto'
import 'server-only'
import { createHmac, timingSafeEqual } from 'node:crypto'
import {
decodeAdminSessionPayload,
requireAdminSessionSecret,
type AdminSession
} from './admin-session'
const JWT_SECRET = process.env.JWT_SECRET || 'D3ROVoice_Super_Secure_Secret_Key_2026_Key!'
const MAX_FAILED_ATTEMPTS = 5
const LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes lockout
const WINDOW_DURATION_MS = 5 * 60 * 1000 // 5 minutes attempt window
@ -45,7 +50,10 @@ export function checkRateLimit(clientKey: string): { allowed: boolean; retryAfte
/**
* Records a failed login attempt and locks the client if threshold is exceeded.
*/
export function recordFailedAttempt(clientKey: string): { locked: boolean; retryAfterSeconds: number } {
export function recordFailedAttempt(clientKey: string): {
locked: boolean
retryAfterSeconds: number
} {
const now = Date.now()
const record = failedAttemptsMap.get(clientKey)
@ -53,7 +61,7 @@ export function recordFailedAttempt(clientKey: string): { locked: boolean; retry
failedAttemptsMap.set(clientKey, {
count: 1,
firstAttemptAt: now,
lockedUntil: null,
lockedUntil: null
})
return { locked: false, retryAfterSeconds: 0 }
}
@ -79,10 +87,11 @@ export function resetFailedAttempts(clientKey: string): void {
/**
* Cryptographically signs a session payload with HMAC-SHA256.
*/
export function signSession(payload: Record<string, unknown>): string {
export function signSession(payload: AdminSession): string {
const sessionSecret = requireAdminSessionSecret()
const jsonStr = JSON.stringify(payload)
const encodedPayload = Buffer.from(jsonStr).toString('base64url')
const hmac = crypto.createHmac('sha256', JWT_SECRET)
const hmac = createHmac('sha256', sessionSecret)
hmac.update(encodedPayload)
const signature = hmac.digest('base64url')
return `${encodedPayload}.${signature}`
@ -92,31 +101,30 @@ export function signSession(payload: Record<string, unknown>): string {
* Verifies and decodes a cryptographically signed session token.
* Uses timingSafeEqual to prevent timing attacks.
*/
export function verifySession<T = Record<string, unknown>>(tokenString: string): T | null {
export function verifySession(tokenString: string): AdminSession | null {
try {
const sessionSecret = requireAdminSessionSecret()
const parts = tokenString.split('.')
if (parts.length !== 2) return null
const [encodedPayload, providedSignature] = parts
const hmac = crypto.createHmac('sha256', JWT_SECRET)
hmac.update(encodedPayload)
const expectedSignature = hmac.digest('base64url')
if (!/^[A-Za-z0-9_-]+$/.test(providedSignature)) return null
const providedBuf = Buffer.from(providedSignature, 'base64url')
if (providedBuf.length !== 32) return null
const providedBuf = Buffer.from(providedSignature)
const expectedBuf = Buffer.from(expectedSignature)
const hmac = createHmac('sha256', sessionSecret)
hmac.update(encodedPayload)
const expectedBuf = hmac.digest()
if (providedBuf.length !== expectedBuf.length) return null
if (!crypto.timingSafeEqual(providedBuf, expectedBuf)) return null
if (!timingSafeEqual(providedBuf, expectedBuf)) return null
const jsonStr = Buffer.from(encodedPayload, 'base64url').toString('utf-8')
const parsed = JSON.parse(jsonStr) as T & { expiresAt?: number }
if (parsed.expiresAt && parsed.expiresAt <= Date.now()) {
return null
}
return parsed
return decodeAdminSessionPayload(encodedPayload)
} catch {
return null
}
}
export function validateAdminRuntimeSecurity(): void {
requireAdminSessionSecret()
}

View file

@ -0,0 +1,57 @@
import 'server-only'
import { getSupabaseAdminClient } from './supabase-admin'
/** Monthly list price per tier, in USD. */
const TIER_MONTHLY_USD: Record<'pro' | 'pro_plus' | 'free', number> = {
pro: 9.9,
pro_plus: 19.9,
free: 0,
}
export interface SubscriptionRevenue {
mrrUsd: number
arrUsd: number
activeCount: number
tierBreakdown: { pro: number; pro_plus: number; free: number }
}
/**
* Aggregates real subscription revenue from Supabase.
* Only status === 'active' subscriptions contribute to MRR/ARR.
* MRR = Σ(monthly price of each active subscription's tier); ARR = MRR * 12.
*/
export async function fetchSubscriptionRevenue(): Promise<SubscriptionRevenue> {
const supabase = await getSupabaseAdminClient('manager')
const rows: Array<Record<string, unknown>> = []
for (let page = 0; page < 100; page += 1) {
const { data, error } = await supabase
.from('subscriptions')
.select('tier, status')
.range(page * 1000, page * 1000 + 999)
if (error) throw new Error('Supabase subscription revenue query failed')
rows.push(...((data ?? []) as Array<Record<string, unknown>>))
if ((data?.length ?? 0) < 1000) break
if (page === 99) throw new Error('Supabase subscription directory exceeds the supported administrative window')
}
const tierBreakdown = { pro: 0, pro_plus: 0, free: 0 }
let mrrUsd = 0
let activeCount = 0
for (const row of rows) {
if (row.status !== 'active') continue
activeCount += 1
const tier = row.tier
if (tier === 'pro' || tier === 'pro_plus' || tier === 'free') {
tierBreakdown[tier] += 1
mrrUsd += TIER_MONTHLY_USD[tier]
}
}
mrrUsd = Math.round(mrrUsd * 100) / 100
const arrUsd = Math.round(mrrUsd * 12 * 100) / 100
return { mrrUsd, arrUsd, activeCount, tierBreakdown }
}

View file

@ -0,0 +1,106 @@
import 'server-only'
import { createClient, type SupabaseClient, type User as AuthUser } from '@supabase/supabase-js'
import { AdminBackendError, requireVerifiedBackendSession } from './backend-session'
import type { AdminRole } from './admin-session'
import type { UserItem } from './api-server'
export function isSupabaseAdminConfigured(): boolean {
const url = (process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL)?.trim()
return Boolean(url && process.env.SUPABASE_SERVICE_ROLE_KEY?.trim())
}
function requireSupabaseConfiguration(): { url: string; serviceRoleKey: string } {
const url = (process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL)?.trim()
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim()
if (!url || !serviceRoleKey) throw new Error('Supabase admin integration is not configured')
const parsed = new URL(url)
const developmentLocalHost =
process.env.NODE_ENV !== 'production' &&
['127.0.0.1', 'localhost', 'host.docker.internal'].includes(parsed.hostname)
if (parsed.protocol !== 'https:' && !developmentLocalHost) {
throw new Error('Supabase admin URL must use HTTPS')
}
return { url: parsed.origin, serviceRoleKey }
}
export async function getSupabaseAdminClient(minimumRole: AdminRole = 'manager'): Promise<SupabaseClient> {
const session = await requireVerifiedBackendSession(minimumRole)
const { url, serviceRoleKey } = requireSupabaseConfiguration()
const client = createClient(url, serviceRoleKey, {
auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false },
global: { headers: { 'X-Client-Info': 'd3ro-admin-server' } }
})
const { data, error } = await client.rpc('resolve_external_admin_actor_v1', {
p_email: session.email,
p_minimum_role: minimumRole
})
if (error) {
if (error.code === '42501') throw new AdminBackendError('admin_supabase_identity_forbidden', 403)
throw new AdminBackendError('admin_supabase_identity_unavailable', 503)
}
const actor = Array.isArray(data) ? data[0] as Record<string, unknown> | undefined : undefined
if (!actor || typeof actor.actor_id !== 'string' || typeof actor.actor_role !== 'string') {
throw new AdminBackendError('admin_supabase_identity_invalid', 502)
}
return client
}
function isActiveAuthUser(user: AuthUser): boolean {
if (!user.banned_until) return true
return Date.parse(user.banned_until) <= Date.now()
}
export async function fetchProductUsers(): Promise<UserItem[]> {
const supabase = await getSupabaseAdminClient()
const authUsers: AuthUser[] = []
for (let page = 1; page <= 100; page += 1) {
const { data, error } = await supabase.auth.admin.listUsers({ page, perPage: 1000 })
if (error) throw new Error('Supabase user directory failed')
authUsers.push(...data.users)
if (data.users.length < 1000) break
if (page === 100) throw new Error('Supabase user directory exceeds the supported administrative window')
}
const userIds = authUsers.map((user) => user.id)
if (userIds.length === 0) return []
const idChunks: string[][] = []
for (let index = 0; index < userIds.length; index += 200) idChunks.push(userIds.slice(index, index + 200))
const relatedRows = await Promise.all(idChunks.map(async (ids) => {
const [profiles, subscriptions] = await Promise.all([
supabase.from('profiles').select('id, name, tier, role').in('id', ids),
supabase.from('subscriptions').select('user_id, tier').in('user_id', ids)
])
if (profiles.error || subscriptions.error) throw new Error('Supabase user account relations failed')
return { profiles: profiles.data ?? [], subscriptions: subscriptions.data ?? [] }
}))
const profiles = relatedRows.flatMap((rows) => rows.profiles)
const subscriptions = relatedRows.flatMap((rows) => rows.subscriptions)
const profileMap = new Map((profiles ?? []).map((profile) => [profile.id as string, profile]))
const subscriptionMap = new Map((subscriptions ?? []).map((subscription) => [subscription.user_id as string, subscription]))
return authUsers.map((user) => {
const profile = profileMap.get(user.id)
const subscription = subscriptionMap.get(user.id)
const role = profile?.role
const normalizedRole: UserItem['role'] = role === 'manager' || role === 'admin' || role === 'super_admin' ? role : 'user'
const tier = subscription?.tier ?? profile?.tier
const normalizedTier: UserItem['tier'] = tier === 'pro' || tier === 'pro_plus' || tier === 'free' ? tier : null
return {
id: user.id,
uid: user.id,
email: user.email ?? '',
name: typeof profile?.name === 'string' && profile.name.trim() ? profile.name : user.email ?? user.id,
role: normalizedRole,
tier: normalizedTier,
createdAt: user.created_at,
lastLoginAt: user.last_sign_in_at ?? null,
lastActiveDevice: null,
isActive: isActiveAuthUser(user),
dailyUsage: null
}
})
}