diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..53c17a4 --- /dev/null +++ b/.env.example @@ -0,0 +1,72 @@ +# Copy this file to .env for local operations only. Never commit populated values. + +# NAS deployment target +DSM_HOST= +DSM_SSH_PORT=22 +DSM_SSH_USER= +DSM_SSH_PASSWORD= +NAS_DEPLOY_ROOT=/volume1/docker/d3ro-voice +NAS_ADMIN_PORT=5000 +NAS_LANDING_PORT=4321 + +# Git/Forgejo operations +GIT_SERVER_URL=https://git.example.invalid +GIT_USERNAME= +GIT_PASSWORD= +GIT_REPO_NAME=d3ro-voice +FORGEJO_TOKEN= +FORGEJO_USERNAME= +FORGEJO_PASSWORD= + +# Public service addresses +APP_DOMAIN=voice.example.invalid +ADMIN_DOMAIN=admin.voice.example.invalid +API_SERVER_URL=https://voice.example.invalid +SITE_URL=https://voice.example.invalid +CORS_ALLOWED_ORIGINS=https://voice.example.invalid,https://admin.voice.example.invalid +ALLOWED_HOSTS=voice.example.invalid;admin.voice.example.invalid + +# API authentication. Generate unique random values for every environment. +# JWT_SECRET and ADMIN_SESSION_SECRET must each be at least 32 UTF-8 bytes. +JWT_SECRET= +JWT_ISSUER=https://voice.example.invalid +JWT_AUDIENCE=d3ro-admin +ADMIN_BOOTSTRAP_TOKEN= +ADMIN_SESSION_SECRET= +# TLS 없이 LAN HTTP(예: http://NAS_IP:3001)로 직접 접속하는 배포에서만 false로 설정. +# Secure 쿠키는 HTTP에서 브라우저가 버려 로그인이 유지되지 않는다. 기본값 true 유지 권장. +ADMIN_COOKIE_SECURE=true +# Shared only between Supabase stt-proxy and the .NET provider orchestrator. +# Use a distinct random value with at least 32 UTF-8 bytes. +D3RO_API_TOKEN= + +# Supabase +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= + +# Google OAuth and browser automation +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_SECRET= +D3RO_PORTAL_OPERATOR_EMAIL= + +# Android release signing. Prefer Gradle properties or a protected CI secret store. +D3RO_RELEASE_STORE_FILE= +D3RO_RELEASE_STORE_PASSWORD= +D3RO_RELEASE_KEY_ALIAS= +D3RO_RELEASE_KEY_PASSWORD= + +# Google Mobile Ads production identifiers. These are identifiers, not secrets, +# but release builds intentionally fail when they are absent or use test IDs. +D3RO_ADMOB_APP_ID= +D3RO_ADMOB_BANNER_UNIT_ID= +D3RO_ADMOB_REWARDED_UNIT_ID= + +# Email and push providers +RESEND_FROM= +RESEND_API_KEY= +FCM_SERVICE_ACCOUNT_JSON= +FCM_PROJECT_ID= + +# Optional authenticated mobile E2E account, stored only in CI secrets or local .env. +MOBILE_E2E_EMAIL= +MOBILE_E2E_PASSWORD= diff --git a/apps/admin/Dockerfile b/apps/admin/Dockerfile index 5576278..743fe40 100644 --- a/apps/admin/Dockerfile +++ b/apps/admin/Dockerfile @@ -1,12 +1,12 @@ # apps/admin/Dockerfile -FROM node:20-alpine AS base +FROM node:24-alpine AS base FROM base AS builder WORKDIR /app COPY package*.json tsconfig*.json ./ COPY packages ./packages COPY apps/admin ./apps/admin -RUN npm install +RUN npm ci RUN npm run build --workspace=@d3ro/admin FROM base AS runner @@ -17,6 +17,7 @@ ENV HOSTNAME="0.0.0.0" COPY --from=builder /app/apps/admin/.next/standalone ./ COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static +COPY apps/admin/start.mjs ./apps/admin/start.mjs EXPOSE 3001 -CMD ["node", "apps/admin/server.js"] +CMD ["node", "apps/admin/start.mjs"] diff --git a/apps/admin/__tests__/admin-edge.local.integration.mjs b/apps/admin/__tests__/admin-edge.local.integration.mjs new file mode 100644 index 0000000..2322fd6 --- /dev/null +++ b/apps/admin/__tests__/admin-edge.local.integration.mjs @@ -0,0 +1,289 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { createClient } from '@supabase/supabase-js' + +const url = process.env.D3RO_LOCAL_SUPABASE_URL?.trim() ?? '' +const anonKey = process.env.D3RO_LOCAL_SUPABASE_ANON_KEY?.trim() ?? '' +const serviceRoleKey = process.env.D3RO_LOCAL_SUPABASE_SERVICE_ROLE_KEY?.trim() ?? '' +if (!url.startsWith('http://127.0.0.1:55321') || anonKey.length < 20 || serviceRoleKey.length < 20) { + throw new Error('D3RO local Supabase credentials for port 55321 are required') +} + +const clientOptions = { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false } } +const service = createClient(url, serviceRoleKey, clientOptions) +const suffix = randomUUID() +const createdUserIds = [] +let assertions = 0 + +function checked(value, message) { + assert.ok(value, message) + assertions += 1 +} + +async function createActor(label, role) { + const email = `d3ro.edge.${suffix}.${label}@example.com` + const password = `D3ro-${suffix}-${label}-Strong!` + const { data, error } = await service.auth.admin.createUser({ + email, + password, + email_confirm: true, + app_metadata: { role }, + user_metadata: { name: `Edge ${label}` } + }) + assert.equal(error, null, `${label} creation failed: ${error?.message ?? ''}`) + createdUserIds.push(data.user.id) + const profile = await service.from('profiles').update({ role }).eq('id', data.user.id) + assert.equal(profile.error, null, `${label} profile setup failed`) + + const login = createClient(url, anonKey, clientOptions) + const session = await login.auth.signInWithPassword({ email, password }) + assert.equal(session.error, null, `${label} sign-in failed`) + return { id: data.user.id, email, token: session.data.session.access_token } +} + +async function invoke(functionName, options = {}) { + const headers = { apikey: anonKey, Accept: 'application/json' } + if (options.token) headers.Authorization = `Bearer ${options.token}` + if (options.body !== undefined) headers['Content-Type'] = 'application/json' + if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey + const response = await fetch(`${url}/functions/v1/${functionName}${options.query ?? ''}`, { + method: options.method ?? 'GET', + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }) + const text = await response.text() + let payload = null + try { + payload = text ? JSON.parse(text) : null + } catch { + payload = { invalidJson: text } + } + return { status: response.status, payload } +} + +function requireStatus(result, status, code, label) { + assert.equal(result.status, status, `${label}: ${JSON.stringify(result.payload)}`) + if (code) assert.equal(result.payload?.error, code, `${label}: wrong stable error code`) + assertions += code ? 2 : 1 +} + +try { + const manager = await createActor('manager', 'manager') + const staleManager = await createActor('stale-manager', 'manager') + const admin = await createActor('admin', 'admin') + const superAdmin = await createActor('super', 'super_admin') + const target = await createActor('target', 'user') + + const anonymous = await invoke('admin-users') + checked(anonymous.status === 401, `anonymous request must be denied, received ${anonymous.status}`) + + requireStatus( + await invoke('admin-users', { token: target.token }), + 403, + 'admin_forbidden', + 'ordinary user list access', + ) + const staleProfileDemotion = await service.from('profiles').update({ role: 'user' }).eq('id', staleManager.id) + assert.equal(staleProfileDemotion.error, null, 'stale manager profile demotion failed') + const staleAuthDemotion = await service.auth.admin.updateUserById(staleManager.id, { app_metadata: { role: 'user' } }) + assert.equal(staleAuthDemotion.error, null, 'stale manager auth demotion failed') + requireStatus( + await invoke('admin-users', { token: staleManager.token }), + 403, + 'admin_forbidden', + 'demoted manager stale token read', + ) + requireStatus( + await invoke('admin-users', { token: manager.token, query: '?page=NaN&limit=20' }), + 400, + 'invalid_pagination', + 'NaN pagination', + ) + requireStatus( + await invoke('admin-users', { token: manager.token, query: '?debug=true' }), + 400, + 'unexpected_query_parameter', + 'unknown query parameter', + ) + const detail = await invoke('admin-users', { token: manager.token, query: `?userId=${target.id}` }) + requireStatus(detail, 200, null, 'manager user detail') + checked(detail.payload?.account?.email === target.email, 'user detail must come from the real auth account') + + requireStatus( + await invoke('admin-users', { + token: manager.token, + method: 'PATCH', + idempotencyKey: randomUUID(), + body: { userId: target.id, newRole: 'manager', memo: 'manager escalation attempt' }, + }), + 403, + 'admin_forbidden', + 'manager role mutation', + ) + + const roleKey = randomUUID() + const roleBody = { userId: target.id, newRole: 'manager', memo: 'verified support assignment' } + const roleChanged = await invoke('admin-users', { + token: admin.token, + method: 'PATCH', + idempotencyKey: roleKey, + body: roleBody, + }) + requireStatus(roleChanged, 200, null, 'admin role mutation') + checked(roleChanged.payload?.newRole === 'manager', 'role response must match the requested role') + const roleReplay = await invoke('admin-users', { + token: admin.token, + method: 'PATCH', + idempotencyKey: roleKey, + body: roleBody, + }) + requireStatus(roleReplay, 200, null, 'role idempotent replay') + checked(JSON.stringify(roleReplay.payload) === JSON.stringify(roleChanged.payload), 'idempotent response changed') + const { count: roleAuditCount } = await service.from('audit_log').select('id', { count: 'exact', head: true }) + .eq('admin_id', admin.id).eq('action', 'user.role_change').eq('target_id', target.id) + checked(roleAuditCount === 1, 'role replay duplicated audit rows') + + requireStatus( + await invoke('admin-users', { + token: admin.token, + method: 'PATCH', + idempotencyKey: roleKey, + body: { ...roleBody, memo: 'different request using the same key' }, + }), + 409, + 'idempotency_conflict', + 'role idempotency mismatch', + ) + requireStatus( + await invoke('admin-users', { + token: admin.token, + method: 'PATCH', + idempotencyKey: randomUUID(), + body: { userId: target.id, newRole: 'admin', memo: 'unauthorized privileged role assignment' }, + }), + 403, + 'super_admin_required', + 'admin privileged role assignment', + ) + + const superRole = await invoke('admin-users', { + token: superAdmin.token, + method: 'PATCH', + idempotencyKey: randomUUID(), + body: { userId: target.id, newRole: 'admin', memo: 'approved privileged role assignment' }, + }) + requireStatus(superRole, 200, null, 'super admin privileged role assignment') + + const subscriptionRemoval = await service.from('subscriptions').delete().eq('user_id', target.id) + assert.equal(subscriptionRemoval.error, null, 'target subscription fixture removal failed') + const subscriptionKey = randomUUID() + const subscriptionBody = { + userId: target.id, + tier: 'pro', + status: 'active', + overageCredits: 25, + adminNote: 'verified support grant', + memo: 'approved manual subscription grant', + } + const subscriptionCreated = await invoke('admin-subscriptions', { + token: admin.token, + method: 'POST', + idempotencyKey: subscriptionKey, + body: subscriptionBody, + }) + requireStatus(subscriptionCreated, 201, null, 'subscription creation') + checked(!JSON.stringify(subscriptionCreated.payload).includes('provider_resource_id'), 'subscription mutation exposed provider resource id') + checked(!JSON.stringify(subscriptionCreated.payload).includes('payple_payer_id'), 'subscription mutation exposed Payple payer id') + const subscriptionReplay = await invoke('admin-subscriptions', { + token: admin.token, + method: 'POST', + idempotencyKey: subscriptionKey, + body: subscriptionBody, + }) + requireStatus(subscriptionReplay, 201, null, 'subscription create replay') + checked(JSON.stringify(subscriptionReplay.payload) === JSON.stringify(subscriptionCreated.payload), 'subscription replay response changed') + + const updated = await invoke('admin-subscriptions', { + token: manager.token, + method: 'PATCH', + query: `?userId=${target.id}`, + idempotencyKey: randomUUID(), + body: { tier: 'pro_plus', overageCredits: 50, memo: 'verified entitlement correction' }, + }) + requireStatus(updated, 200, null, 'manager subscription update') + checked(updated.payload?.subscription?.tier === 'pro_plus', 'updated tier must be returned from the RPC') + + requireStatus( + await invoke('admin-subscriptions', { + token: manager.token, + method: 'DELETE', + query: `?userId=${target.id}`, + idempotencyKey: randomUUID(), + body: { memo: 'manager delete attempt' }, + }), + 403, + 'admin_forbidden', + 'manager subscription delete', + ) + requireStatus( + await invoke('admin-subscriptions', { + token: manager.token, + method: 'PATCH', + query: `?userId=${target.id}`, + idempotencyKey: randomUUID(), + body: { overageCredits: -1, memo: 'invalid negative credit update' }, + }), + 400, + 'invalid_overageCredits', + 'negative overage credits', + ) + const { data: unchanged } = await service.from('subscriptions').select('overage_credits').eq('user_id', target.id).single() + checked(unchanged?.overage_credits === 50, 'failed update changed subscription data') + + const paymentHistory = await invoke('admin-payments', { token: manager.token, query: `?userId=${target.id}` }) + requireStatus(paymentHistory, 200, null, 'database payment history') + checked(Array.isArray(paymentHistory.payload?.providerEvents), 'provider event ledger must be returned as an array') + checked(!JSON.stringify(paymentHistory.payload).includes('payload_digest'), 'payment payload digest must not be exposed') + checked(!JSON.stringify(paymentHistory.payload).includes('provider_resource_id'), 'provider resource identifier must not be exposed') + requireStatus( + await invoke('admin-payments', { token: manager.token, query: `?userId=${target.id}&source=payple` }), + 501, + 'payple_live_history_not_configured', + 'live Payple history', + ) + + const sensitiveAudit = await service.from('audit_log').insert({ + admin_id: admin.id, + action: 'subscription.redaction_test', + target_type: 'subscription', + target_id: target.id, + before_data: { tier: 'pro', payple_payer_id: 'payer-secret' }, + after_data: { tier: 'pro_plus', provider_resource_id: 'resource-secret', payload: { raw: 'provider-payload' } }, + memo: 'verify audit response redaction', + }) + assert.equal(sensitiveAudit.error, null, 'sensitive audit fixture insertion failed') + const audit = await invoke('admin-audit-log', { token: manager.token, query: `?target_id=${target.id}&limit=100` }) + requireStatus(audit, 200, null, 'audit log read') + checked(audit.payload?.logs?.some((entry) => entry.action === 'user.role_change'), 'role audit row is missing') + checked(audit.payload?.logs?.some((entry) => entry.action === 'subscription.update'), 'subscription audit row is missing') + const encodedAudit = JSON.stringify(audit.payload) + checked(!encodedAudit.includes('payer-secret') && !encodedAudit.includes('resource-secret') && !encodedAudit.includes('provider-payload'), 'audit response exposed provider identifiers or payload') + + const deleted = await invoke('admin-subscriptions', { + token: admin.token, + method: 'DELETE', + query: `?userId=${target.id}`, + idempotencyKey: randomUUID(), + body: { memo: 'approved subscription retirement' }, + }) + requireStatus(deleted, 200, null, 'admin subscription soft delete') + checked(deleted.payload?.subscription?.tier === 'free' && deleted.payload?.subscription?.status === 'expired', 'soft delete response is invalid') + + console.log(`admin Edge local integration: ${assertions} assertions passed`) +} finally { + if (createdUserIds.length > 0) { + await service.from('audit_log').delete().in('admin_id', createdUserIds) + await service.from('admin_operation_requests').delete().in('actor_id', createdUserIds) + for (const userId of createdUserIds.reverse()) await service.auth.admin.deleteUser(userId) + } +} diff --git a/apps/admin/__tests__/admin-management.local.integration.mjs b/apps/admin/__tests__/admin-management.local.integration.mjs new file mode 100644 index 0000000..f7f7287 --- /dev/null +++ b/apps/admin/__tests__/admin-management.local.integration.mjs @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { createClient } from '@supabase/supabase-js' + +const url = process.env.D3RO_LOCAL_SUPABASE_URL?.trim() ?? '' +const anonKey = process.env.D3RO_LOCAL_SUPABASE_ANON_KEY?.trim() ?? '' +const serviceRoleKey = process.env.D3RO_LOCAL_SUPABASE_SERVICE_ROLE_KEY?.trim() ?? '' +if (!url.startsWith('http://127.0.0.1:55321') || anonKey.length < 20 || serviceRoleKey.length < 20) { + throw new Error('D3RO local Supabase credentials for port 55321 are required') +} + +const options = { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false } } +const service = createClient(url, serviceRoleKey, options) +const anonymous = createClient(url, anonKey, options) +const suffix = randomUUID() +const createdUserIds = [] +let assertions = 0 + +function checked(value, message) { + assert.ok(value, message) + assertions += 1 +} + +async function createActor(label, role) { + const email = `d3ro.admin.${suffix}.${label}@example.com` + const { data, error } = await service.auth.admin.createUser({ + email, + password: `D3ro-${suffix}-${label}-Strong!`, + email_confirm: true, + app_metadata: { role }, + user_metadata: { name: label } + }) + assert.equal(error, null, `${label} creation failed: ${error?.message ?? ''}`) + createdUserIds.push(data.user.id) + const profileUpdate = await service.from('profiles').update({ role }).eq('id', data.user.id) + assert.equal(profileUpdate.error, null, `${label} profile role setup failed`) + return { id: data.user.id, email } +} + +function requireSuccess(result, label) { + assert.equal(result.error, null, `${label}: ${result.error?.message ?? 'unknown error'}`) + checked(result.data?.success === true, `${label}: success response missing`) + return result.data +} + +function requireFailure(result, pattern, label) { + assert.ok(result.error, `${label}: unexpectedly succeeded`) + assert.match(`${result.error.code ?? ''} ${result.error.message ?? ''}`, pattern, `${label}: wrong error`) + assertions += 1 +} + +try { + const superAdmin = await createActor('super', 'super_admin') + const admin = await createActor('admin', 'admin') + const manager = await createActor('manager', 'manager') + const target = await createActor('target', 'user') + + requireFailure(await anonymous.rpc('admin_change_user_role_v1', { + p_actor_email: admin.email, + p_idempotency_key: randomUUID(), + p_target_user_id: target.id, + p_new_role: 'manager', + p_memo: 'anonymous bypass attempt' + }), /permission denied|42501/i, 'anonymous RPC execution') + + requireFailure(await service.rpc('admin_change_user_role_v1', { + p_actor_email: manager.email, + p_idempotency_key: randomUUID(), + p_target_user_id: target.id, + p_new_role: 'manager', + p_memo: 'manager escalation attempt' + }), /insufficient_admin_role|42501/i, 'manager role mutation') + + const roleKey = randomUUID() + const roleArgs = { + p_actor_email: admin.email, + p_idempotency_key: roleKey, + p_target_user_id: target.id, + p_new_role: 'manager', + p_memo: 'verified customer support assignment' + } + const roleChanged = requireSuccess(await service.rpc('admin_change_user_role_v1', roleArgs), 'admin role change') + const roleReplayed = requireSuccess(await service.rpc('admin_change_user_role_v1', roleArgs), 'role idempotent replay') + checked(JSON.stringify(roleChanged) === JSON.stringify(roleReplayed), 'role replay response changed') + + const [{ data: targetProfile }, { data: targetAuth }, { count: roleAuditCount }] = await Promise.all([ + service.from('profiles').select('role').eq('id', target.id).single(), + service.auth.admin.getUserById(target.id), + service.from('audit_log').select('id', { count: 'exact', head: true }).eq('admin_id', admin.id).eq('action', 'user.role_change').eq('target_id', target.id) + ]) + checked(targetProfile?.role === 'manager', 'profile role was not updated') + checked(targetAuth?.user?.app_metadata?.role === 'manager', 'auth app_metadata role was not updated') + checked(roleAuditCount === 1, 'idempotent role replay duplicated audit rows') + + requireFailure(await service.rpc('admin_change_user_role_v1', { + ...roleArgs, + p_memo: 'different request with reused key' + }), /idempotency_key_reused|22023/i, 'role idempotency mismatch') + + requireFailure(await service.rpc('admin_change_user_role_v1', { + p_actor_email: admin.email, + p_idempotency_key: randomUUID(), + p_target_user_id: target.id, + p_new_role: 'admin', + p_memo: 'unauthorized privileged assignment' + }), /super_admin_required|42501/i, 'admin privileged role assignment') + + requireSuccess(await service.rpc('admin_change_user_role_v1', { + p_actor_email: superAdmin.email, + p_idempotency_key: randomUUID(), + p_target_user_id: target.id, + p_new_role: 'admin', + p_memo: 'approved privileged assignment' + }), 'super admin privileged assignment') + + await service.from('subscriptions').delete().eq('user_id', target.id) + const subscriptionKey = randomUUID() + const createArgs = { + p_actor_email: admin.email, + p_idempotency_key: subscriptionKey, + p_action: 'create', + p_user_id: target.id, + p_tier: 'pro', + p_status: 'active', + p_overage_credits: 25, + p_admin_note: 'verified support grant', + p_memo: 'approved manual subscription grant' + } + const createdSubscription = requireSuccess(await service.rpc('admin_mutate_subscription_v1', createArgs), 'subscription create') + checked(!JSON.stringify(createdSubscription).includes('provider_resource_id'), 'subscription RPC exposed provider resource id') + checked(!JSON.stringify(createdSubscription).includes('payple_payer_id'), 'subscription RPC exposed Payple payer id') + requireSuccess(await service.rpc('admin_mutate_subscription_v1', createArgs), 'subscription create replay') + const { count: createAuditCount } = await service.from('audit_log').select('id', { count: 'exact', head: true }) + .eq('admin_id', admin.id).eq('action', 'subscription.create').eq('target_id', target.id) + checked(createAuditCount === 1, 'subscription replay duplicated audit rows') + + requireSuccess(await service.rpc('admin_mutate_subscription_v1', { + p_actor_email: manager.email, + p_idempotency_key: randomUUID(), + p_action: 'update', + p_user_id: target.id, + p_tier: 'pro_plus', + p_status: 'active', + p_overage_credits: 50, + p_memo: 'customer support entitlement correction' + }), 'manager subscription update') + const [{ data: updatedSubscription }, { data: updatedProfile }] = await Promise.all([ + service.from('subscriptions').select('tier, overage_credits').eq('user_id', target.id).single(), + service.from('profiles').select('tier').eq('id', target.id).single() + ]) + checked(updatedSubscription?.tier === 'pro_plus' && updatedSubscription?.overage_credits === 50, 'subscription update was not persisted') + checked(updatedProfile?.tier === 'pro_plus', 'profile tier was not synchronized') + + requireFailure(await service.rpc('admin_mutate_subscription_v1', { + p_actor_email: manager.email, + p_idempotency_key: randomUUID(), + p_action: 'delete', + p_user_id: target.id, + p_memo: 'manager delete attempt' + }), /admin_role_required|42501/i, 'manager subscription delete') + + requireFailure(await service.rpc('admin_mutate_subscription_v1', { + p_actor_email: admin.email, + p_idempotency_key: randomUUID(), + p_action: 'update', + p_user_id: target.id, + p_overage_credits: -1, + p_memo: 'invalid negative credit mutation' + }), /invalid_overage_credits|22023/i, 'negative credits validation') + const { data: unchanged } = await service.from('subscriptions').select('overage_credits').eq('user_id', target.id).single() + checked(unchanged?.overage_credits === 50, 'failed mutation changed subscription data') + + requireSuccess(await service.rpc('admin_mutate_subscription_v1', { + p_actor_email: admin.email, + p_idempotency_key: randomUUID(), + p_action: 'delete', + p_user_id: target.id, + p_memo: 'approved subscription retirement' + }), 'admin subscription soft delete') + const [{ data: deletedSubscription }, { data: deletedProfile }] = await Promise.all([ + service.from('subscriptions').select('tier, status').eq('user_id', target.id).single(), + service.from('profiles').select('tier').eq('id', target.id).single() + ]) + checked(deletedSubscription?.tier === 'free' && deletedSubscription?.status === 'expired', 'soft delete state is invalid') + checked(deletedProfile?.tier === 'free', 'soft delete did not synchronize profile tier') + + console.log(`admin management integration: ${assertions} assertions passed`) +} finally { + if (createdUserIds.length > 0) { + await service.from('audit_log').delete().in('admin_id', createdUserIds) + await service.from('admin_operation_requests').delete().in('actor_id', createdUserIds) + for (const userId of createdUserIds.reverse()) await service.auth.admin.deleteUser(userId) + } +} diff --git a/apps/admin/__tests__/admin.local.browser.e2e.mjs b/apps/admin/__tests__/admin.local.browser.e2e.mjs new file mode 100644 index 0000000..0fd4a1b --- /dev/null +++ b/apps/admin/__tests__/admin.local.browser.e2e.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict' +import { chromium } from 'playwright' + +const baseUrl = process.env.D3RO_ADMIN_E2E_URL?.trim() ?? '' +if (!baseUrl.startsWith('https://127.0.0.1:')) throw new Error('Local HTTPS admin E2E URL is required') + +const browser = await chromium.launch({ headless: true }) +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true }) +await page.addInitScript(() => { + window.alert = () => undefined + window.confirm = () => true + window.prompt = () => 'browser verified endpoint deletion' +}) +const consoleErrors = [] +const serverErrors = [] +page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) +}) +page.on('response', (response) => { + if (response.status() >= 500) serverErrors.push(`${response.status()} ${response.url()}`) +}) + +try { + await page.goto(`${baseUrl}/`, { waitUntil: 'networkidle' }) + await page.getByLabel('Admin Identifier (Username / Email)').fill('admin.browser.e2e@example.com') + await page.getByLabel('Password').fill('Admin-browser-e2e-strong-password!') + await Promise.all([ + page.waitForURL(`${baseUrl}/`), + page.getByRole('button', { name: 'Sign In as Super Admin' }).click() + ]) + await page.getByText('BACKEND CONNECTED').waitFor() + + const endpointPayload = await page.evaluate(async () => { + const response = await fetch('/api/admin/backend/endpoints', { cache: 'no-store' }) + return { status: response.status, body: await response.json() } + }) + assert.equal(endpointPayload.status, 200) + assert.ok(Array.isArray(endpointPayload.body)) + assert.ok(endpointPayload.body.every((endpoint) => endpoint.apiKey === '' || endpoint.apiKey === '••••••••')) + + await page.goto(`${baseUrl}/models`, { waitUntil: 'networkidle' }) + await page.getByRole('tab', { name: /LLM & Reasoning Model Endpoints/ }).click() + const addModelButton = page.getByRole('button', { name: /Add LLM Model Endpoint/ }) + if (await addModelButton.count() === 0) { + const cookies = await page.context().cookies() + const bodyText = (await page.locator('body').innerText()).slice(0, 1200) + throw new Error(`models diagnostic url=${page.url()} cookies=${cookies.map((cookie) => `${cookie.name}:${cookie.secure}:${cookie.sameSite}`).join(',')} body=${bodyText}`) + } + await addModelButton.click() + await page.getByLabel('Model ID').fill('browser-e2e-model') + await page.getByLabel('Display Name').fill('Browser E2E Model') + await page.getByLabel('Endpoint URL').fill('https://models.example.com/v1') + await page.getByLabel('Audit memo').last().fill('browser verified endpoint creation') + const createResponse = page.waitForResponse((response) => response.url().includes('/api/admin/backend/endpoints') && response.request().method() === 'POST') + await page.getByRole('button', { name: 'Save Endpoint' }).click() + const created = await createResponse + assert.equal(created.status(), 200, `model creation failed: ${await created.text()}`) + await page.getByText('Browser E2E Model').waitFor() + + const modelRow = page.locator('tr').filter({ hasText: 'browser-e2e-model' }) + const deleteResponse = page.waitForResponse((response) => response.url().includes('/api/admin/backend/endpoints/') && response.request().method() === 'DELETE') + await modelRow.getByRole('button', { name: 'Delete' }).click() + const deleted = await deleteResponse + assert.equal(deleted.status(), 200, `model deletion failed: ${await deleted.text()}`) + await page.getByText('Browser E2E Model').waitFor({ state: 'detached' }) + + await page.goto(`${baseUrl}/users`, { waitUntil: 'networkidle' }) + const customerRow = page.locator('tr').filter({ hasText: 'customer.browser.e2e@example.com' }) + await customerRow.waitFor() + const customerHref = await customerRow.getByRole('link').first().getAttribute('href') + assert.match(customerHref ?? '', /^\/users\/[0-9a-f-]{36}$/) + const customerId = customerHref.split('/').pop() + + await page.goto(`${baseUrl}${customerHref}`, { waitUntil: 'networkidle' }) + await page.getByRole('button', { name: 'Change Role' }).click() + const roleDialog = page.getByRole('dialog', { name: /CHANGE USER ROLE/ }) + await roleDialog.waitFor() + await roleDialog.getByRole('combobox').click() + await page.getByRole('option', { name: 'manager' }).click() + await page.getByPlaceholder('Reason for role change (required)...').fill('browser verified support assignment') + await page.getByRole('button', { name: 'Change Role', exact: true }).last().click() + await page.getByText('MANAGER').first().waitFor() + + await page.goto(`${baseUrl}/subscriptions/${customerId}`, { waitUntil: 'networkidle' }) + await page.getByRole('combobox', { name: 'Tier' }).click() + await page.getByRole('option', { name: 'PRO', exact: true }).click() + await page.getByLabel('Memo (required for audit log)').fill('browser verified subscription update') + await page.getByRole('button', { name: 'Update' }).click() + await page.getByText('Operation successful').waitFor() + + await page.goto(`${baseUrl}/audit-log`, { waitUntil: 'networkidle' }) + await page.getByText('user.role_change').first().waitFor() + await page.getByText('subscription.update').first().waitFor() + await page.screenshot({ path: 'scratch/admin-browser-e2e.png', fullPage: true }) + + assert.deepEqual(serverErrors, [], `server errors: ${serverErrors.join(', ')}`) + assert.deepEqual(consoleErrors, [], `console errors: ${consoleErrors.join(', ')}`) + console.log('admin browser E2E: login, JWT proxy, model create/delete, users, role, subscription, audit GREEN') +} finally { + await browser.close() +} diff --git a/apps/admin/__tests__/browser-fixture.local.mjs b/apps/admin/__tests__/browser-fixture.local.mjs new file mode 100644 index 0000000..3ffbfc8 --- /dev/null +++ b/apps/admin/__tests__/browser-fixture.local.mjs @@ -0,0 +1,57 @@ +import { createClient } from '@supabase/supabase-js' + +const action = process.argv[2] +const url = process.env.D3RO_LOCAL_SUPABASE_URL?.trim() ?? '' +const serviceRoleKey = process.env.D3RO_LOCAL_SUPABASE_SERVICE_ROLE_KEY?.trim() ?? '' +if (!url.startsWith('http://127.0.0.1:55321') || serviceRoleKey.length < 20) { + throw new Error('D3RO local Supabase service credentials are required') +} + +const client = createClient(url, serviceRoleKey, { + auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false } +}) +const adminEmail = 'admin.browser.e2e@example.com' +const targetEmail = 'customer.browser.e2e@example.com' + +async function matchingUsers() { + const { data, error } = await client.auth.admin.listUsers({ page: 1, perPage: 1000 }) + if (error) throw error + return data.users.filter((user) => user.email === adminEmail || user.email === targetEmail) +} + +async function cleanup() { + const users = await matchingUsers() + const ids = users.map((user) => user.id) + if (ids.length === 0) return + await client.from('audit_log').delete().in('admin_id', ids) + await client.from('admin_operation_requests').delete().in('actor_id', ids) + for (const user of users) await client.auth.admin.deleteUser(user.id) +} + +if (action === 'cleanup') { + await cleanup() + console.log('admin browser fixture cleaned') +} else if (action === 'setup') { + await cleanup() + const admin = await client.auth.admin.createUser({ + email: adminEmail, + password: 'Admin-browser-e2e-strong-password!', + email_confirm: true, + app_metadata: { role: 'super_admin' }, + user_metadata: { name: 'Browser E2E Admin' } + }) + if (admin.error) throw admin.error + const target = await client.auth.admin.createUser({ + email: targetEmail, + password: 'Customer-browser-e2e-strong-password!', + email_confirm: true, + app_metadata: { role: 'user' }, + user_metadata: { name: 'Browser E2E Customer' } + }) + if (target.error) throw target.error + const roleUpdate = await client.from('profiles').update({ role: 'super_admin' }).eq('id', admin.data.user.id) + if (roleUpdate.error) throw roleUpdate.error + console.log(`admin browser fixture ready target=${target.data.user.id}`) +} else { + throw new Error('Expected setup or cleanup') +} diff --git a/apps/admin/package.json b/apps/admin/package.json index a35741a..2f675b2 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -21,7 +21,8 @@ "@mui/material-nextjs": "^7.0.0", "@supabase/ssr": "^0.10.0", "@supabase/supabase-js": "^2.103.0", - "next": "^15.0.0", + "lucide-react": "^1.33.0", + "next": "16.3.1", "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^2.15.0" diff --git a/apps/admin/src/app/(admin)/ads/page.tsx b/apps/admin/src/app/(admin)/ads/page.tsx index c957d3f..745ba9f 100644 --- a/apps/admin/src/app/(admin)/ads/page.tsx +++ b/apps/admin/src/app/(admin)/ads/page.tsx @@ -1,562 +1,280 @@ // apps/admin/src/app/(admin)/ads/page.tsx -// D3RO Voice — Multi-Ad Network Mediation & Revenue Settlement Console (10+ Demand Sources) +// D3RO Voice Admin CRM — Ad Mediation & Monetization Console -import React from 'react' -import { Box, Typography, Button } from '@mui/material' -import { - C, - FONT_SANS, - FONT_MONO, - panelSx, - tableSx, - statusBadgeSx, - primaryButtonSx, -} from '@/lib/console-theme' +import { Box, Typography } from '@mui/material' import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds' +import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme' +import { requireManager } from '@/lib/admin-guard' +import { MEDIATION_ROSTER, fetchAdRewardStats, type AdRewardStats } from '@/lib/ad-monetization' +import { isSupabaseAdminConfigured } from '@/lib/supabase-admin' -interface AdNetworkStat { - id: string - name: string - adapterType: string - format: string - impressions: number - clicks: number - ctr: string - ecpm: number - grossRevenueUsd: number - fillRate: string - status: 'active' | 'bidding' | 'fallback' +export const dynamic = 'force-dynamic' + +const FORMAT_LABEL: Record = { + banner_dock: 'Bottom Dock', + rewarded_video: 'Rewarded Video', + export_sponsor: 'Export Sponsor', + audio_chime: 'Audio Chime' } -interface SettlementRow { - id: string - cycleMonth: string - networkName: string - grossUsd: number - withholdingTax: string - netPayoutKrw: number - payoutStatus: 'settled' | 'paid' | 'pending' - method: string +function formatDateTime(value: string): string { + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? '—' : parsed.toISOString().replace('T', ' ').slice(0, 16) } -export default async function AdminAdsPage(): Promise { - // 10+ Production Ad Networks Active in D3RO Voice Mediation - const networks: AdNetworkStat[] = [ - { - id: 'net_001', - name: 'Direct House Sponsor Engine', - adapterType: 'Direct Contract', - format: 'Bottom Dock / Video / Export', - impressions: 84000, - clicks: 4200, - ctr: '5.0%', - ecpm: 15.2, - grossRevenueUsd: 1276.8, - fillRate: '100.0%', - status: 'active', - }, - { - id: 'net_002', - name: 'Playwire RAMP Engine', - adapterType: 'Header Bidding SSP', - format: 'Desktop Video & Display', - impressions: 62000, - clicks: 4340, - ctr: '7.0%', - ecpm: 8.4, - grossRevenueUsd: 520.8, - fillRate: '96.2%', - status: 'bidding', - }, - { - id: 'net_003', - name: 'AppLovin MAX', - adapterType: 'Real-Time In-App Bidding', - format: 'Rewarded Video (15s)', - impressions: 48000, - clicks: 3840, - ctr: '8.0%', - ecpm: 7.8, - grossRevenueUsd: 374.4, - fillRate: '94.5%', - status: 'bidding', - }, - { - id: 'net_004', - name: 'Unity LevelPlay', - adapterType: 'Rewarded Video SDK', - format: 'Rewarded Quota Refill', - impressions: 45000, - clicks: 4050, - ctr: '9.0%', - ecpm: 9.1, - grossRevenueUsd: 409.5, - fillRate: '95.1%', - status: 'active', - }, - { - id: 'net_005', - name: 'EthicalAds Privacy Dev Network', - adapterType: 'REST Decision API', - format: 'Bottom Dock Banner', - impressions: 38000, - clicks: 608, - ctr: '1.6%', - ecpm: 3.8, - grossRevenueUsd: 144.4, - fillRate: '99.4%', - status: 'active', - }, - { - id: 'net_006', - name: 'Carbon Ads (BuySellAds)', - adapterType: 'Native JSON Endpoint', - format: 'Tech Developer Unit', - impressions: 31000, - clicks: 589, - ctr: '1.9%', - ecpm: 4.2, - grossRevenueUsd: 130.2, - fillRate: '98.8%', - status: 'active', - }, - { - id: 'net_007', - name: 'Google Ad Manager 360', - adapterType: 'Universal Ad Server', - format: 'Global Display & Video', - impressions: 29000, - clicks: 580, - ctr: '2.0%', - ecpm: 3.5, - grossRevenueUsd: 101.5, - fillRate: '99.8%', - status: 'bidding', - }, - { - id: 'net_008', - name: 'Mintegral APAC Network', - adapterType: 'Video & Playable SDK', - format: 'Rewarded Video (15s)', - impressions: 22000, - clicks: 1760, - ctr: '8.0%', - ecpm: 6.2, - grossRevenueUsd: 136.4, - fillRate: '92.4%', - status: 'bidding', - }, - { - id: 'net_009', - name: 'InMobi Programmatic Exchange', - adapterType: 'RTB Exchange', - format: 'Banner & Video Interstitial', - impressions: 19000, - clicks: 380, - ctr: '2.0%', - ecpm: 3.4, - grossRevenueUsd: 64.6, - fillRate: '91.2%', - status: 'active', - }, - { - id: 'net_010', - name: 'PubMatic OpenWrap SSP', - adapterType: 'Prebid Header Bidding', - format: 'Bottom Dock & Export Modal', - impressions: 15000, - clicks: 225, - ctr: '1.5%', - ecpm: 3.6, - grossRevenueUsd: 54.0, - fillRate: '90.5%', - status: 'bidding', - }, - ] - - const totalGrossRevenue = networks.reduce((acc, n) => acc + n.grossRevenueUsd, 0) - const totalImpressions = networks.reduce((acc, n) => acc + n.impressions, 0) - const avgEcpm = (totalGrossRevenue / (totalImpressions / 1000)).toFixed(2) - - // Monthly Settlement Records (KRW Tax Withholding & Net Payout) - const settlements: SettlementRow[] = [ - { - id: 'STL-202607-DIRECT', - cycleMonth: '2026-07', - networkName: 'Direct House Sponsor (Cursor/Notion)', - grossUsd: 1276.8, - withholdingTax: '3.3% (₩56,870)', - netPayoutKrw: 1666810, - payoutStatus: 'paid', - method: 'KB국민 928702-00-184920', - }, - { - id: 'STL-202607-PLAYWIRE', - cycleMonth: '2026-07', - networkName: 'Playwire RAMP Desktop Header Bidding', - grossUsd: 520.8, - withholdingTax: '3.3% (₩23,200)', - netPayoutKrw: 679880, - payoutStatus: 'paid', - method: 'Wire Transfer (USD)', - }, - { - id: 'STL-202607-APPLOVIN', - cycleMonth: '2026-07', - networkName: 'AppLovin MAX In-App Bidding', - grossUsd: 374.4, - withholdingTax: '3.3% (₩16,680)', - netPayoutKrw: 488760, - payoutStatus: 'settled', - method: 'Wire Transfer (USD)', - }, - { - id: 'STL-202607-UNITY', - cycleMonth: '2026-07', - networkName: 'Unity LevelPlay Rewarded Video', - grossUsd: 409.5, - withholdingTax: '3.3% (₩18,240)', - netPayoutKrw: 534580, - payoutStatus: 'settled', - method: 'PayPal (yunchanpaca@gmail.com)', - }, - { - id: 'STL-202607-ETHICAL', - cycleMonth: '2026-07', - networkName: 'EthicalAds Privacy Dev Network', - grossUsd: 144.4, - withholdingTax: '3.3% (₩6,430)', - netPayoutKrw: 188510, - payoutStatus: 'settled', - method: 'PayPal (yunchanpaca@gmail.com)', - }, - ] - - const totalSettledKrw = settlements.reduce((acc, s) => acc + s.netPayoutKrw, 0) - +function HeaderBar(): React.ReactElement { return ( - <> - {/* Header */} - + + + - Multi-Ad Mediation & Revenue Settlement Hub + Ad Mediation & Monetization - 10 NETWORKS ACTIVE - AUCTION HEALTHY + + FAIL-CLOSED SANDBOX + - - Real-time header bidding mediation, floor eCPM management, and automated tax withholding settlement ledger. + + Mediation waterfall roster, rewarded token grants, network integration state - - - - - - - {/* Publisher Account Banner */} - - - - P - - - - Registered Publisher Account: yunchanpaca@gmail.com - - - Payout Beneficiary: D3RO Voice AI • KB국민은행 928702-00-184920 • 사업자등록 120-88-01923 - - - - - KYC Verified - 3.3% 원천징수 적용 - - - - {/* KPI Cards */} - - - - - Est. Monthly Ad Revenue - - - ${totalGrossRevenue.toFixed(2)} - - - ₩{(totalGrossRevenue * 1350).toLocaleString()} (환율 ₩1,350) - - - - - - - - Weighted Avg. eCPM - - - ${avgEcpm} - - - Floor: $2.00 min • Max: $18.00 - - - - - - - - Total Ad Impressions - - - {(totalImpressions / 1000).toFixed(1)}k - - - Avg. Fill Rate: 96.8% - - - - - - - - Net Payout Settled (KRW) - - - ₩{(totalSettledKrw / 10000).toFixed(1)}만 - - - ₩{totalSettledKrw.toLocaleString()} 입금 완료 - - - - - - {/* 10+ Multi-Ad Network Mediation Matrix */} - - - - - 10+ Active Ad Networks & Header Bidding Matrix - - - First-price real-time bidding auction with sub-800ms SLA fallback to Direct House AI Sponsors. - - - AUCTION TIMEOUT: 800MS - - - - - - - Demand Partner - Adapter Protocol - Primary Slot Format - Impressions - CTR - Bid eCPM - Gross Revenue - Fill Rate - Status - - - - {networks.map((net) => ( - - {net.name} - {net.adapterType} - {net.format} - - {net.impressions.toLocaleString()} - - {net.ctr} - - ${net.ecpm.toFixed(2)} - - - ${net.grossRevenueUsd.toFixed(2)} - - {net.fillRate} - - - {net.status.toUpperCase()} - - - - ))} - - - - - - {/* Monthly Settlement & Tax Withholding Payout Ledger */} - - - - - Monthly Revenue Settlement & Payout Ledger - - - Net-30 / Net-60 cycle settlements with automatic 3.3% Korean withholding tax deduction. - - - TAX WITHHOLDING AUTO-CALCULATED - - - - - - - Settlement ID - Cycle Month - Network Source - Gross ($ USD) - Withholding Tax - Net Payout (₩ KRW) - Beneficiary Method - Payout Status - - - - {settlements.map((s) => ( - - {s.id} - {s.cycleMonth} - {s.networkName} - - ${s.grossUsd.toFixed(2)} - - - {s.withholdingTax} - - - ₩{s.netPayoutKrw.toLocaleString()} - - {s.method} - - - {s.payoutStatus.toUpperCase()} - - - - ))} - - - - - + + ) +} + +function RewardKpis({ stats }: { stats: AdRewardStats }): React.ReactElement { + const cards = [ + { + title: `Rewarded Claims (${stats.windowDays}d)`, + value: stats.totalClaims.toLocaleString(), + subtext: 'Verified rewarded-video completions' + }, + { + title: `Tokens Granted (${stats.windowDays}d)`, + value: stats.totalRewardTokens.toLocaleString(), + subtext: 'Cloud AI tokens issued via ad rewards' + }, + { + title: `Unique Claimants (${stats.windowDays}d)`, + value: stats.uniqueClaimants.toLocaleString(), + subtext: 'Distinct accounts that redeemed rewards' + }, + { + title: 'Live Ad Networks', + value: `0 / ${MEDIATION_ROSTER.length}`, + subtext: 'All adapters fail-closed until SDK contracts land' + } + ] + return ( + + {cards.map((card) => ( + + + {card.title} + + + {card.value} + + {card.subtext} + + ))} + + ) +} + +function MediationRosterPanel(): React.ReactElement { + return ( + + + + Mediation Waterfall Roster + + NO LIVE BIDS + + + 데스크톱 미디에이션 엔진에 등록된 어댑터 구성입니다. 공식 SDK/인증 계약이 연결되기 전까지 모든 네트워크는 + 입찰 없이 fail-closed로 동작하며, 데모 크리에이티브와 가짜 수익 수치는 표시하지 않습니다. + + + + + + Network + Adapter ID + Formats + Floor eCPM + Integration + + + + {MEDIATION_ROSTER.map((network) => ( + + + {network.name} + + + {network.networkId} + + + {network.formats.map((format) => FORMAT_LABEL[format] ?? format).join(' · ')} + + + ${network.floorEcpm.toFixed(2)} + + + NOT INTEGRATED + + + ))} + + + + + ) +} + +function RewardBreakdownPanel({ stats }: { stats: AdRewardStats }): React.ReactElement { + return ( + + + + Rewarded Grants by Network + + {stats.byNetwork.length === 0 ? ( + + 최근 {stats.windowDays}일간 검증된 리워드 클레임이 없습니다. + + ) : ( + + + + + Network + Claims + Tokens + + + + {stats.byNetwork.map((summary) => ( + + + {summary.network} + + + {summary.claims.toLocaleString()} + + + +{summary.rewardTokens.toLocaleString()} + + + ))} + + + + )} + + + + + Recent Verified Claims + + {stats.recentClaims.length === 0 ? ( + 표시할 클레임이 없습니다. + ) : ( + + + + + Verified At (UTC) + Network + Placement + Tokens + + + + {stats.recentClaims.map((claim) => ( + + + {formatDateTime(claim.verifiedAt)} + + + {claim.network} + + {claim.placement} + + +{claim.rewardTokens.toLocaleString()} + + + ))} + + + + )} + + + ) +} + +function RewardsUnavailablePanel({ reason }: { reason: string }): React.ReactElement { + return ( + + + Rewarded 토큰 지급 통계를 불러올 수 없습니다. + + {reason} + + ) +} + +export default async function AdminAdsPage(): Promise { + await requireManager() + + let stats: AdRewardStats | null = null + let statsError: string | null = null + if (!isSupabaseAdminConfigured()) { + statsError = + 'SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 ad_reward_claims 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다.' + } else { + try { + stats = await fetchAdRewardStats() + } catch (error) { + statsError = error instanceof Error ? error.message : 'Unknown ad reward stats error' + } + } + + return ( + + + {stats && } + + {stats ? : } + ) } diff --git a/apps/admin/src/app/(admin)/audit-log/[id]/page.tsx b/apps/admin/src/app/(admin)/audit-log/[id]/page.tsx index 63ce180..21c64b6 100644 --- a/apps/admin/src/app/(admin)/audit-log/[id]/page.tsx +++ b/apps/admin/src/app/(admin)/audit-log/[id]/page.tsx @@ -4,10 +4,13 @@ import { Box, Typography } from '@mui/material' import { C, FONT_SANS, FONT_MONO, panelSx, statusBadgeSx } from '@/lib/console-theme' import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds' -import { getSupabaseServerClient } from '@/lib/supabase-server' +import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin' +import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel' import { requireManager } from '@/lib/admin-guard' import Link from 'next/link' +import { notFound } from 'next/navigation' import { AuditDiffViewer } from '@/components/audit-diff-viewer' +import { sanitizeAuditSnapshot } from '@/lib/audit-sanitize' interface PageProps { params: Promise<{ id: string }> @@ -15,40 +18,36 @@ interface PageProps { export default async function AuditLogDetailPage({ params }: PageProps): Promise { await requireManager() - const { id } = await params - const supabase = await getSupabaseServerClient() - - const { data: log } = await supabase - .from('audit_log') - .select('*') - .eq('id', parseInt(id, 10)) - .maybeSingle() - - const typedLog = log || { - id: parseInt(id, 10) || 101, - admin_id: 'usr_d3ro_001', - action: 'MODEL_ENDPOINT_UPDATE', - target_type: 'model', - target_id: 'whisper-large-v3-turbo', - created_at: '2026-08-19T10:45:00Z', - memo: 'Enabled 6.2x turbo acceleration, updated model path to weights/large-v3-turbo.pt and tuned dual-condition parallel buffer flush threshold.', - before_data: { - model_id: 'whisper-large-v3', - acceleration: '1.0x', - latency_ms: 880, - buffer_flush: 'sequential', - is_default: true, - }, - after_data: { - model_id: 'whisper-large-v3-turbo', - acceleration: '6.2x', - latency_ms: 142, - buffer_flush: 'parallel-dual-condition', - is_default: true, - }, + if (!isSupabaseAdminConfigured()) { + return ( + + ) } + const { id } = await params + const logId = Number.parseInt(id, 10) + if (!Number.isSafeInteger(logId) || logId <= 0) notFound() + const supabase = await getSupabaseAdminClient() - const adminName = 'D3RO System Administrator' + const { data: log, error: logError } = await supabase + .from('audit_log') + .select('id, admin_id, action, target_type, target_id, before_data, after_data, memo, created_at') + .eq('id', logId) + .maybeSingle() + if (logError) throw new Error(`Supabase audit detail failed: ${logError.message}`) + if (!log) notFound() + const typedLog = log + + const { data: adminProfile, error: adminError } = await supabase + .from('profiles') + .select('name') + .eq('id', typedLog.admin_id) + .maybeSingle() + if (adminError) throw new Error(`Supabase audit actor failed: ${adminError.message}`) + const adminName = typeof adminProfile?.name === 'string' && adminProfile.name ? adminProfile.name : 'Unknown administrator' return ( <> @@ -82,7 +81,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise sx={{ fontFamily: FONT_SANS, fontSize: '20px', - fontWeight: 700, + fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0, @@ -99,7 +98,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise ← Back to Audit Ledger - SHA-256 Checksum Verified + Persisted audit record @@ -111,7 +110,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise {/* Details Card */} - + Transaction Metadata @@ -125,7 +124,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise {/* Memo Card */} - + Administrative Intent & Reason - + Entity State Transition Diff (Before vs After) @@ -157,8 +156,8 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise | null} - afterData={typedLog.after_data as Record | null} + beforeData={sanitizeAuditSnapshot(typedLog.before_data)} + afterData={sanitizeAuditSnapshot(typedLog.after_data)} /> diff --git a/apps/admin/src/app/(admin)/audit-log/page.tsx b/apps/admin/src/app/(admin)/audit-log/page.tsx index d517fa5..b8e3543 100644 --- a/apps/admin/src/app/(admin)/audit-log/page.tsx +++ b/apps/admin/src/app/(admin)/audit-log/page.tsx @@ -4,7 +4,8 @@ import { Box, Typography, Button } from '@mui/material' import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, filterBtnSx, statusBadgeSx } from '@/lib/console-theme' import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds' -import { getSupabaseServerClient } from '@/lib/supabase-server' +import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin' +import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel' import { requireManager } from '@/lib/admin-guard' import Link from 'next/link' @@ -14,36 +15,40 @@ interface PageProps { export default async function AuditLogPage({ searchParams }: PageProps): Promise { await requireManager() + if (!isSupabaseAdminConfigured()) { + return ( + + ) + } const params = await searchParams const targetTypeFilter = params.target_type ?? 'all' - const page = parseInt(params.page ?? '1', 10) + if (!['all', 'subscription', 'profile'].includes(targetTypeFilter)) { + throw new Error('Invalid audit target type filter') + } + const pageValue = params.page ?? '1' + if (!/^[1-9]\d{0,6}$/.test(pageValue)) throw new Error('Invalid audit page') + const page = Number(pageValue) const limit = 20 const from = (page - 1) * limit const to = from + limit - 1 - const supabase = await getSupabaseServerClient() + const supabase = await getSupabaseAdminClient() - let query = supabase.from('audit_log').select('*', { count: 'exact' }) + let query = supabase.from('audit_log').select('id, admin_id, action, target_type, target_id, memo, created_at', { count: 'exact' }) if (targetTypeFilter !== 'all') { query = query.eq('target_type', targetTypeFilter) } - const { data: rawLogs } = await query + const { data: rawLogs, error: logsError } = await query .order('created_at', { ascending: false }) .range(from, to) + if (logsError) throw new Error(`Supabase audit log failed: ${logsError.message}`) - let logs = (rawLogs ?? []) as Array> - - if (logs.length === 0) { - // Rich Mock Security Audit Logs - logs = [ - { id: 101, created_at: '2026-08-19T10:45:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'MODEL_ENDPOINT_UPDATE', target_type: 'model', target_id: 'whisper-large-v3-turbo', memo: 'Enabled 6.2x turbo acceleration & parallel flush' }, - { id: 102, created_at: '2026-08-19T09:12:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'SUBSCRIPTION_UPGRADE', target_type: 'subscription', target_id: 'usr_d3ro_002', memo: 'Upgraded Sarah Kim to PRO+ VIP tier' }, - { id: 103, created_at: '2026-08-18T16:30:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'SECURITY_POLICY_CHECK', target_type: 'system', target_id: 'cors_whitelist', memo: 'Verified CORS allowlist for desktop/web clients' }, - { id: 104, created_at: '2026-08-18T14:15:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'VECTOR_INDEX_REBUILD', target_type: 'vector_rag', target_id: 'sqlite_vec_01', memo: 'Reindexed 4,820 documents with nomic-embed-text' }, - { id: 105, created_at: '2026-08-17T11:00:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'DIARIZATION_THRESHOLD_SET', target_type: 'pipeline', target_id: 'pyannote_3.1', memo: 'Adjusted speaker similarity clustering threshold to 0.72' }, - ] - } + const logs = (rawLogs ?? []) as Array> return ( <> @@ -77,7 +82,7 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise sx={{ fontFamily: FONT_SANS, fontSize: '20px', - fontWeight: 700, + fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0, @@ -86,7 +91,7 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise Security Audit Log & Event Ledger - IMMUTABLE AUDIT TRAIL + PERSISTED AUDIT TRAIL - ADMIN ACTION AUDIT • TIER MODIFICATIONS • ENDPOINT CONFIGURATION TRACE + Admin action audit, role and subscription modifications - {['all', 'subscription', 'profile', 'model', 'system'].map((t) => ( + {['all', 'subscription', 'profile'].map((t) => ( {t.toUpperCase()} @@ -117,11 +122,11 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise {/* Main Table Card */} - + Chronological Security Log Entries - AUTO-SIGN SHA-256 VERIFIED + DATABASE AUDIT RECORDS @@ -138,7 +143,13 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise - {logs.map((log) => ( + {logs.length === 0 ? ( + + + No audit events match the selected filters. + + + ) : logs.map((log) => ( {new Date(log.created_at as string).toLocaleString()} diff --git a/apps/admin/src/app/(admin)/error.tsx b/apps/admin/src/app/(admin)/error.tsx new file mode 100644 index 0000000..56ba38d --- /dev/null +++ b/apps/admin/src/app/(admin)/error.tsx @@ -0,0 +1,22 @@ +'use client' + +import { Alert, Box, Button, Typography } from '@mui/material' + +export default function AdminError({ reset }: { error: Error & { digest?: string }; reset: () => void }): React.ReactElement { + return ( + + + + 관리자 데이터를 불러오지 못했습니다. + + + 인증, 권한 또는 백엔드 연결 오류가 발생했습니다. 샘플 데이터로 대체하지 않습니다. + + + + + + + + ) +} diff --git a/apps/admin/src/app/(admin)/layout.tsx b/apps/admin/src/app/(admin)/layout.tsx index 96ff77d..e6a02ef 100644 --- a/apps/admin/src/app/(admin)/layout.tsx +++ b/apps/admin/src/app/(admin)/layout.tsx @@ -11,7 +11,7 @@ export default async function AdminLayout({ }: { children: React.ReactNode }): Promise { - await requireManager() + const admin = await requireManager() return ( - + ) } - diff --git a/apps/admin/src/app/(admin)/models/page.tsx b/apps/admin/src/app/(admin)/models/page.tsx index ea42dba..4f134b6 100644 --- a/apps/admin/src/app/(admin)/models/page.tsx +++ b/apps/admin/src/app/(admin)/models/page.tsx @@ -24,18 +24,22 @@ import { } from '@mui/material' import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx, primaryButtonSx } from '@/lib/console-theme' import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds' -import { - fetchModelEndpoints, - fetchSttEndpoints, - createSttEndpoint, - updateSttEndpoint, - deleteSttEndpoint, - setDefaultSttEndpoint, - testSttEndpoint, - type ModelEndpoint, - type SttProviderEndpoint, - type STTProviderCategory, +import type { + ModelEndpoint, + SttProviderEndpoint, + STTProviderCategory, } from '@/lib/api-server' +import { + createModelEndpointClient, + createSttEndpointClient, + deleteModelEndpointClient, + deleteSttEndpointClient, + fetchModelEndpointsClient, + fetchSttEndpointsClient, + setDefaultSttEndpointClient, + testSttEndpointClient, + updateSttEndpointClient, +} from '@/lib/backend-admin-client' const PRESET_LLM_MODELS = [ { modelId: 'whisper-large-v3-turbo', modelName: 'Faster-Whisper Large-v3 Turbo (Local)', provider: 'Local Sidecar', endpointUrl: 'http://localhost:8971/stt/transcribe', promptCost: '0.000000', completionCost: '0.000000' }, @@ -50,82 +54,19 @@ const PRESET_STT_PROVIDERS: Array<{ providerType: STTProviderCategory endpointUrl: string modelId: string - method: string + method: 'multipart' | 'binary-stream' | 'json-base64' | 'custom-rest' costPerMinute: number language: string prompt?: string description: string }> = [ - { - name: 'Groq Whisper LPU Turbo (Ultra Fast)', - providerType: 'groq', - endpointUrl: 'https://api.groq.com/openai/v1/audio/transcriptions', - modelId: 'whisper-large-v3-turbo', - method: 'multipart', - costPerMinute: 0.0005, - language: 'ko', - description: 'LPU 가속 기반 초저지연(~140ms) 고속 전사, 극저비용', - }, - { - name: 'OpenAI Whisper Official', - providerType: 'openai', - endpointUrl: 'https://api.openai.com/v1/audio/transcriptions', - modelId: 'whisper-1', - method: 'multipart', - costPerMinute: 0.006, - language: 'ko', - description: 'OpenAI 공식 Whisper-1 모델, 표준 고품질 다국어 인식', - }, - { - name: 'Deepgram Nova-3 Industry Standard', - providerType: 'deepgram', - endpointUrl: 'https://api.deepgram.com/v1/listen', - modelId: 'nova-3', - method: 'binary-stream', - costPerMinute: 0.0043, - language: 'ko', - description: 'Nova-3 스마트 구두점, 실시간 스트리밍 최적화 및 고정밀 전사', - }, - { - name: 'Google Gemini 2.0 Flash / Cloud STT', - providerType: 'google', - endpointUrl: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent', - modelId: 'gemini-2.0-flash', - method: 'json-base64', - costPerMinute: 0.001, - language: 'ko', - description: 'Gemini 2.0 Flash 기반 다국어 및 문맥 인식 오디오 전사', - }, - { - name: 'AssemblyAI Universal-2', - providerType: 'assemblyai', - endpointUrl: 'https://api.assemblyai.com/v2/transcript', - modelId: 'best', - method: 'multipart', - costPerMinute: 0.0025, - language: 'ko', - description: '문맥 인식 음향 모델 및 자동 단락 구분 STT', - }, - { - name: 'Microsoft Azure Speech Service', - providerType: 'azure', - endpointUrl: 'https://koreacentral.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1', - modelId: 'azure-speech', - method: 'binary-stream', - costPerMinute: 0.005, - language: 'ko-KR', - description: 'Azure Cognitive Speech API 엔터프라이즈 음성 인식', - }, - { - name: 'Self-Hosted / Local Faster-Whisper Sidecar', - providerType: 'local-sidecar', - endpointUrl: 'http://localhost:8971/stt/transcribe', - modelId: 'whisper-large-v3-turbo', - method: 'multipart', - costPerMinute: 0.0, - language: 'ko', - description: '사내 프라이빗 서버 또는 로컬 Whisper 사이드카 (완전 무료/오프라인)', - }, + { name: 'Groq Whisper LPU Turbo (Ultra Fast)', providerType: 'groq', endpointUrl: 'https://api.groq.com/openai/v1/audio/transcriptions', modelId: 'whisper-large-v3-turbo', method: 'multipart', costPerMinute: 0.0005, language: 'ko', description: 'LPU 가속 기반 초저지연(~140ms) 고속 전사, 극저비용' }, + { name: 'OpenAI Whisper Official', providerType: 'openai', endpointUrl: 'https://api.openai.com/v1/audio/transcriptions', modelId: 'whisper-1', method: 'multipart', costPerMinute: 0.006, language: 'ko', description: 'OpenAI 공식 Whisper-1 모델, 표준 고품질 다국어 인식' }, + { name: 'Deepgram Nova-3 Industry Standard', providerType: 'deepgram', endpointUrl: 'https://api.deepgram.com/v1/listen', modelId: 'nova-3', method: 'binary-stream', costPerMinute: 0.0043, language: 'ko', description: 'Nova-3 스마트 구두점, 실시간 스트리밍 최적화 및 고정밀 전사' }, + { name: 'Google Gemini 2.0 Flash / Cloud STT', providerType: 'google', endpointUrl: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent', modelId: 'gemini-2.0-flash', method: 'json-base64', costPerMinute: 0.001, language: 'ko', description: 'Gemini 2.0 Flash 기반 다국어 및 문맥 인식 오디오 전사' }, + { name: 'AssemblyAI Universal-2', providerType: 'assemblyai', endpointUrl: 'https://api.assemblyai.com/v2/transcript', modelId: 'best', method: 'multipart', costPerMinute: 0.0025, language: 'ko', description: '문맥 인식 음향 모델 및 자동 단락 구분 STT' }, + { name: 'Microsoft Azure Speech Service', providerType: 'azure', endpointUrl: 'https://koreacentral.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1', modelId: 'azure-speech', method: 'binary-stream', costPerMinute: 0.005, language: 'ko-KR', description: 'Azure Cognitive Speech API 엔터프라이즈 음성 인식' }, + { name: 'Self-Hosted / Local Faster-Whisper Sidecar', providerType: 'local-sidecar', endpointUrl: 'http://localhost:8971/stt/transcribe', modelId: 'whisper-large-v3-turbo', method: 'multipart', costPerMinute: 0.0, language: 'ko', description: '사내 프라이빗 서버 또는 로컬 Whisper 사이드카 (완전 무료/오프라인)' }, ] export default function ServiceModelsPage(): React.ReactElement { @@ -142,39 +83,42 @@ export default function ServiceModelsPage(): React.ReactElement { // STT Form State const [sttName, setSttName] = useState('') - const [sttProviderType, setSttProviderType] = useState('groq') + const [sttProviderType, setSttProviderType] = useState('custom') const [sttEndpointUrl, setSttEndpointUrl] = useState('') const [sttApiKey, setSttApiKey] = useState('') - const [sttModelId, setSttModelId] = useState('whisper-large-v3-turbo') + const [sttModelId, setSttModelId] = useState('') const [sttMethod, setSttMethod] = useState<'multipart' | 'binary-stream' | 'json-base64' | 'custom-rest'>('multipart') const [sttLanguage, setSttLanguage] = useState('ko') const [sttPrompt, setSttPrompt] = useState('') - const [sttCostPerMinute, setSttCostPerMinute] = useState('0.000500') + const [sttCostPerMinute, setSttCostPerMinute] = useState('0') const [sttFallbackPriority, setSttFallbackPriority] = useState(1) const [sttIsDefault, setSttIsDefault] = useState(false) + const [sttMemo, setSttMemo] = useState('') // LLM Endpoints State const [llmEndpoints, setLlmEndpoints] = useState([]) const [llmModalOpen, setLlmModalOpen] = useState(false) const [llmLoading, setLlmLoading] = useState(false) - const [llmPingStatus, setLlmPingStatus] = useState>({}) + const [dataError, setDataError] = useState(null) // LLM Form State const [llmModelId, setLlmModelId] = useState('') const [llmModelName, setLlmModelName] = useState('') - const [llmProvider, setLlmProvider] = useState('OpenAI') + const [llmProvider, setLlmProvider] = useState('Custom') const [llmEndpointUrl, setLlmEndpointUrl] = useState('') const [llmApiKey, setLlmApiKey] = useState('') - const [llmPromptCost, setLlmPromptCost] = useState('0.000150') - const [llmCompletionCost, setLlmCompletionCost] = useState('0.000600') + const [llmPromptCost, setLlmPromptCost] = useState('0') + const [llmCompletionCost, setLlmCompletionCost] = useState('0') + const [llmMemo, setLlmMemo] = useState('') const loadData = async () => { + setDataError(null) try { - const [sttData, llmData] = await Promise.all([fetchSttEndpoints(), fetchModelEndpoints()]) + const [sttData, llmData] = await Promise.all([fetchSttEndpointsClient(), fetchModelEndpointsClient()]) setSttEndpoints(sttData) setLlmEndpoints(llmData) - } catch { - // Fallbacks handled in fetch functions + } catch (error) { + setDataError(error instanceof Error ? error.message : '관리자 백엔드에서 엔드포인트를 불러오지 못했습니다.') } } @@ -184,34 +128,21 @@ export default function ServiceModelsPage(): React.ReactElement { // ── STT Handlers ────────────────────────────────────────────────────────── - const applySttPreset = (presetName: string) => { - const p = PRESET_STT_PROVIDERS.find((x) => x.name === presetName) - if (!p) return - setSttName(p.name) - setSttProviderType(p.providerType) - setSttEndpointUrl(p.endpointUrl) - setSttModelId(p.modelId) - setSttMethod(p.method as SttProviderEndpoint['method']) - setSttCostPerMinute(p.costPerMinute.toFixed(6)) - setSttLanguage(p.language) - if (p.prompt) setSttPrompt(p.prompt) - setSttTestModalResult(null) - } - const openAddSttModal = () => { setSttEditingId(null) setSttName('') - setSttProviderType('groq') - setSttEndpointUrl('https://api.groq.com/openai/v1/audio/transcriptions') + setSttProviderType('custom') + setSttEndpointUrl('') setSttApiKey('') - setSttModelId('whisper-large-v3-turbo') + setSttModelId('') setSttMethod('multipart') setSttLanguage('ko') setSttPrompt('') - setSttCostPerMinute('0.000500') + setSttCostPerMinute('0') setSttFallbackPriority(sttEndpoints.length + 1) setSttIsDefault(sttEndpoints.length === 0) setSttTestModalResult(null) + setSttMemo('') setSttModalOpen(true) } @@ -252,12 +183,13 @@ export default function ServiceModelsPage(): React.ReactElement { isDefault: sttIsDefault, isActive: true, fallbackPriority: sttFallbackPriority, + memo: sttMemo.trim(), } if (sttEditingId) { - await updateSttEndpoint(sttEditingId, payload) + await updateSttEndpointClient(sttEditingId, payload) } else { - await createSttEndpoint(payload) + await createSttEndpointClient(payload) } await loadData() @@ -270,8 +202,10 @@ export default function ServiceModelsPage(): React.ReactElement { } const handleSetDefaultStt = async (id: number) => { + const memo = prompt('기본 STT 공급자 변경 사유를 입력하세요.')?.trim() ?? '' + if (memo.length < 3) return try { - await setDefaultSttEndpoint(id) + await setDefaultSttEndpointClient(id, memo) setSttEndpoints((prev) => prev.map((ep) => ({ ...ep, @@ -285,11 +219,17 @@ export default function ServiceModelsPage(): React.ReactElement { const handlePingStt = async (id: number) => { setSttPingStatus((prev) => ({ ...prev, [id]: 'Testing...' })) - const result = await testSttEndpoint(id) - if (result.success) { - setSttPingStatus((prev) => ({ ...prev, [id]: `⚡ OK • ${result.latencyMs}ms` })) - } else { - setSttPingStatus((prev) => ({ ...prev, [id]: `❌ Failed` })) + try { + const result = await testSttEndpointClient(id) + setSttPingStatus((prev) => ({ + ...prev, + [id]: result.success ? `⚡ OK • ${result.latencyMs}ms` : `❌ ${result.message}` + })) + } catch (error) { + setSttPingStatus((prev) => ({ + ...prev, + [id]: `❌ ${error instanceof Error ? error.message : 'Test failed'}` + })) } } @@ -297,7 +237,7 @@ export default function ServiceModelsPage(): React.ReactElement { setTestingInModal(true) setSttTestModalResult(null) try { - const result = await testSttEndpoint(sttEditingId ?? 0, sttApiKey, sttEndpointUrl) + const result = await testSttEndpointClient(sttEditingId ?? 0, sttApiKey, sttEndpointUrl) setSttTestModalResult({ success: result.success, message: result.message, @@ -316,8 +256,10 @@ export default function ServiceModelsPage(): React.ReactElement { const handleDeleteStt = async (id: number) => { if (!confirm('이 STT 프로바이더 엔드포인트를 삭제하시겠습니까?')) return + const memo = prompt('삭제 사유를 입력하세요.')?.trim() ?? '' + if (memo.length < 3) return try { - await deleteSttEndpoint(id) + await deleteSttEndpointClient(id, memo) setSttEndpoints((prev) => prev.filter((ep) => ep.id !== id)) } catch (err) { alert('Error: ' + (err instanceof Error ? err.message : String(err))) @@ -326,15 +268,16 @@ export default function ServiceModelsPage(): React.ReactElement { // ── LLM Handlers ────────────────────────────────────────────────────────── - const applyLlmPreset = (presetKey: string) => { - const p = PRESET_LLM_MODELS.find((m) => m.modelId === presetKey) - if (!p) return - setLlmModelId(p.modelId) - setLlmModelName(p.modelName) - setLlmProvider(p.provider) - setLlmEndpointUrl(p.endpointUrl) - setLlmPromptCost(p.promptCost) - setLlmCompletionCost(p.completionCost) + const openAddLlmModal = () => { + setLlmModelId('') + setLlmModelName('') + setLlmProvider('Custom') + setLlmEndpointUrl('') + setLlmApiKey('') + setLlmPromptCost('0') + setLlmCompletionCost('0') + setLlmMemo('') + setLlmModalOpen(true) } const handleAddLlmEndpoint = async (e: React.FormEvent) => { @@ -342,26 +285,23 @@ export default function ServiceModelsPage(): React.ReactElement { setLlmLoading(true) try { - const newEp: ModelEndpoint = { - id: Date.now(), + await createModelEndpointClient({ modelId: llmModelId, modelName: llmModelName, - provider: llmProvider as ModelEndpoint['provider'], + provider: llmProvider, endpointUrl: llmEndpointUrl, - apiKey: llmApiKey ? '••••••••' : '', + apiKey: llmApiKey, costPer1kPromptTokens: parseFloat(llmPromptCost), costPer1kCompletionTokens: parseFloat(llmCompletionCost), - latencyMs: 150, - isActive: true, - isDefault: false, - createdAt: new Date().toISOString(), - } - setLlmEndpoints((prev) => [...prev, newEp]) + memo: llmMemo.trim(), + }) + await loadData() setLlmModalOpen(false) setLlmModelId('') setLlmModelName('') setLlmEndpointUrl('') setLlmApiKey('') + setLlmMemo('') } catch (err) { alert('Error: ' + (err instanceof Error ? err.message : String(err))) } finally { @@ -369,12 +309,16 @@ export default function ServiceModelsPage(): React.ReactElement { } } - const handlePingLlm = (_id: number, modelKey: string) => { - setLlmPingStatus((prev) => ({ ...prev, [modelKey]: 'Testing...' })) - setTimeout(() => { - const lat = Math.floor(Math.random() * 80 + 40) - setLlmPingStatus((prev) => ({ ...prev, [modelKey]: `OK • ${lat}ms` })) - }, 500) + const handleDeleteLlm = async (id: number) => { + if (!confirm('이 LLM 엔드포인트를 삭제하시겠습니까?')) return + const memo = prompt('삭제 사유를 입력하세요.')?.trim() ?? '' + if (memo.length < 3) return + try { + await deleteModelEndpointClient(id, memo) + setLlmEndpoints((current) => current.filter((endpoint) => endpoint.id !== id)) + } catch (error) { + alert('Error: ' + (error instanceof Error ? error.message : String(error))) + } } const defaultStt = sttEndpoints.find((e) => e.isDefault) || sttEndpoints[0] @@ -411,7 +355,7 @@ export default function ServiceModelsPage(): React.ReactElement { sx={{ fontFamily: FONT_SANS, fontSize: '20px', - fontWeight: 700, + fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0, @@ -432,7 +376,7 @@ export default function ServiceModelsPage(): React.ReactElement { mt: 0.25, }} > - DYNAMIC ROUTING • AUTO FAILOVER • TOKEN & PER-MINUTE BILLING • INSTANT DEFAULT SWITCH + Dynamic routing, auto failover, token and per-minute billing, instant default switch @@ -443,13 +387,19 @@ export default function ServiceModelsPage(): React.ReactElement { + Add STT Provider Endpoint ) : ( - )} + {dataError && ( + + {dataError} + + )} + {/* Tabs Navigation */} - + Active Default Cloud STT: {defaultStt.name} @@ -545,7 +495,7 @@ export default function ServiceModelsPage(): React.ReactElement { - + Configured Cloud Transcription Providers @@ -581,7 +531,7 @@ export default function ServiceModelsPage(): React.ReactElement { ) : ( sttEndpoints.map((ep) => ( - + #{ep.fallbackPriority} @@ -606,7 +556,7 @@ export default function ServiceModelsPage(): React.ReactElement { {ep.isDefault ? ( - + ⭐ DEFAULT ) : ( @@ -691,7 +641,7 @@ export default function ServiceModelsPage(): React.ReactElement { {activeTab === 'llm' && ( - + Active AI Reasoning & Action Endpoints @@ -757,8 +707,8 @@ export default function ServiceModelsPage(): React.ReactElement { @@ -798,35 +748,44 @@ export default function ServiceModelsPage(): React.ReactElement { }, }} > - + {sttEditingId ? 'Edit STT Provider Endpoint' : 'Add Cloud STT Provider Endpoint'} - {/* Quick Preset Selector */} - - Load Preset Template - - - + {sttEditingId === null && ( + + ⚡ Load Preset Template (optional) + + + )} + setSttMemo(event.target.value)} + required + helperText="변경 사유를 3자 이상 입력하세요." + size="small" + sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'rgba(10, 17, 31, 0.7)', color: C.bright, borderRadius: '10px' } }} + /> + {/* Test Connection Inside Modal */} @@ -1029,7 +998,7 @@ export default function ServiceModelsPage(): React.ReactElement { - @@ -1052,33 +1021,40 @@ export default function ServiceModelsPage(): React.ReactElement { }, }} > - + Add Reasoning Model Endpoint - Load Preset Template + ⚡ Load Preset Template (optional) - + setLlmMemo(event.target.value)} + required + helperText="생성 사유를 3자 이상 입력하세요." + size="small" + sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'rgba(10, 17, 31, 0.7)', color: C.bright, borderRadius: '10px' } }} + /> - diff --git a/apps/admin/src/app/(admin)/page.tsx b/apps/admin/src/app/(admin)/page.tsx index d312316..dd0a21f 100644 --- a/apps/admin/src/app/(admin)/page.tsx +++ b/apps/admin/src/app/(admin)/page.tsx @@ -3,21 +3,61 @@ import { Box, Typography, Button } from '@mui/material' import { fetchServerStats } from '@/lib/api-server' +import { isSupabaseAdminConfigured } from '@/lib/supabase-admin' +import { fetchSubscriptionRevenue, type SubscriptionRevenue } from '@/lib/subscription-metrics' import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme' import { DoubleBezelCard, StatRing, TactileBadge } from '@d3ro/ui/components/ds' -import { DashboardSimulator } from '@/components/dashboard-simulator' import Link from 'next/link' export default async function AdminOverviewPage(): Promise { const stats = await fetchServerStats() + const nonOperationalNodes = stats.nodes.filter((node) => node.status !== 'operational') + + let revenue: SubscriptionRevenue | null = null + if (isSupabaseAdminConfigured()) { + try { + revenue = await fetchSubscriptionRevenue() + } catch { + revenue = null + } + } + + const revenueCards = revenue + ? [ + { + title: 'Annual Recurring Revenue', + value: `$${revenue.arrUsd.toLocaleString()}`, + subtext: 'Active subscriptions × 12 months', + color: 'green' as const, + badge: 'ARR', + badgeColor: 'green' as const, + }, + { + title: 'Monthly Recurring Revenue', + value: `$${revenue.mrrUsd.toLocaleString()}`, + subtext: `Pro ${revenue.tierBreakdown.pro.toLocaleString()} · Pro+ ${revenue.tierBreakdown.pro_plus.toLocaleString()}`, + color: 'blue' as const, + badge: 'MRR', + badgeColor: 'blue' as const, + }, + { + title: 'Active Subscriptions', + value: revenue.activeCount.toLocaleString(), + subtext: 'Supabase subscriptions with status = active', + color: 'purple' as const, + badge: 'BILLING', + badgeColor: 'purple' as const, + }, + ] + : [] const bentoCards = [ { - title: 'Annual Recurring Revenue (ARR)', - value: `$${stats.arrUsd.toLocaleString()}`, - subtext: `MRR: $${stats.mrrUsd.toLocaleString()} • +18.4% MoM Growth`, + title: 'Backend Uptime', + value: `${Math.floor(stats.serverUptimeSeconds / 3600).toLocaleString()}h`, + subtext: 'Measured by the active .NET API process', color: 'purple' as const, - badge: 'REVENUE', + badge: 'RUNTIME', badgeColor: 'purple' as const, icon: ( @@ -26,11 +66,11 @@ export default async function AdminOverviewPage(): Promise { ), }, { - title: 'Active Voice & Meeting Sessions', + title: 'Active Admin Accounts Today', value: `${stats.activeUsersToday.toLocaleString()} Active`, - subtext: `${stats.totalUsers.toLocaleString()} Total Users • 18 Realtime Streams`, + subtext: `${stats.totalUsers.toLocaleString()} administrator accounts`, color: 'blue' as const, - badge: 'VOICE STREAMS', + badge: 'AUTH', badgeColor: 'blue' as const, icon: ( @@ -52,11 +92,11 @@ export default async function AdminOverviewPage(): Promise { ), }, { - title: 'Speaker Diarization Accuracy', - value: `${stats.pipelines.meetingIntelligence.speakerAccuracyPercent}%`, - subtext: `${stats.pipelines.meetingIntelligence.templatesGeneratedToday} Meeting Docs • 42 Mindmaps`, + title: 'Recorded Backend Errors', + value: stats.errorCount.toLocaleString(), + subtext: 'Persisted server error log entries', color: 'orange' as const, - badge: 'PHASE 15.5', + badge: 'ERROR LOG', badgeColor: 'orange' as const, icon: ( @@ -98,7 +138,7 @@ export default async function AdminOverviewPage(): Promise { sx={{ fontFamily: FONT_SANS, fontSize: '20px', - fontWeight: 700, + fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0, @@ -107,7 +147,7 @@ export default async function AdminOverviewPage(): Promise { Unified Dashboard Overview - ONLINE • v0.2.1-alpha + BACKEND CONNECTED { mt: 0.25, }} > - REALTIME AI TELEMETRY • ARR & SUBSCRIPTION METRICS • PIPELINE HEALTH + Backend runtime counters, error ledger, reported node health @@ -149,6 +189,70 @@ export default async function AdminOverviewPage(): Promise { {/* Main Content Area */} + {/* Revenue KPI Row */} + {revenueCards.length > 0 && ( + + {revenueCards.map((card) => ( + + + + $ + + + {card.badge} + + + + + {card.title} + + + + {card.value} + + + + {card.subtext} + + + ))} + + )} + {/* Executive Bento Grid */} { sx={{ fontFamily: FONT_SANS, fontSize: '11px', - fontWeight: 600, + fontWeight: 500, color: C.dim, - textTransform: 'uppercase', - letterSpacing: '0.06em', + letterSpacing: '0.04em', }} > {card.title} @@ -191,7 +294,7 @@ export default async function AdminOverviewPage(): Promise { sx={{ fontFamily: FONT_MONO, fontSize: '26px', - fontWeight: 700, + fontWeight: 500, color: C.bright, my: 0.75, }} @@ -216,15 +319,19 @@ export default async function AdminOverviewPage(): Promise { - + System Nodes & Pipeline Topology - 6 NODES HEALTHY • ZERO SERVICE DEGRADATION DETECTED + {stats.nodes.length === 0 + ? 'NODE TELEMETRY NOT REPORTED' + : `${stats.nodes.length} REPORTED • ${nonOperationalNodes.length} NON-OPERATIONAL`} - - ALL OPERATIONAL + + {stats.nodes.length === 0 + ? 'UNAVAILABLE' + : nonOperationalNodes.length === 0 ? 'ALL REPORTED OPERATIONAL' : 'ATTENTION REQUIRED'} @@ -235,7 +342,11 @@ export default async function AdminOverviewPage(): Promise { gap: 2, }} > - {stats.nodes.map((node) => ( + {stats.nodes.length === 0 ? ( + + No node-health telemetry has been reported by the backend. + + ) : stats.nodes.map((node) => ( { }} > - + {node.name} { width: 8, height: 8, borderRadius: '50%', - bgcolor: '#10b981', - boxShadow: '0 0 8px #10b981', + bgcolor: node.status === 'operational' ? '#10b981' : node.status === 'degraded' ? '#f59e0b' : '#ef4444', + boxShadow: `0 0 8px ${node.status === 'operational' ? '#10b981' : node.status === 'degraded' ? '#f59e0b' : '#ef4444'}`, }} /> @@ -274,8 +385,8 @@ export default async function AdminOverviewPage(): Promise { Latency: {node.latencyMs}ms - - {node.uptimePercent}% Up + + {node.uptimePercent}% · {node.status.toUpperCase()} @@ -283,17 +394,14 @@ export default async function AdminOverviewPage(): Promise { - {/* Live Audio & Voice Intelligence Simulator Widget */} - - {/* Server Operational Telemetry Logs */} - + Operational Telemetry & Server Logs - AUTO REFRESH (30s) + SERVER SNAPSHOT @@ -312,7 +420,7 @@ export default async function AdminOverviewPage(): Promise { {stats.recentErrors.length === 0 ? ( - ✓ NO OPERATIONAL ERRORS — ALL C# .NET API NODES HEALTHY (100% SUCCESS RATE) + No backend errors have been recorded. ) : ( diff --git a/apps/admin/src/app/(admin)/pipelines/page.tsx b/apps/admin/src/app/(admin)/pipelines/page.tsx index 6ccb10f..6801825 100644 --- a/apps/admin/src/app/(admin)/pipelines/page.tsx +++ b/apps/admin/src/app/(admin)/pipelines/page.tsx @@ -8,6 +8,18 @@ import { StatRing, TactileBadge, DoubleBezelCard } from '@d3ro/ui/components/ds' export default async function PipelinesPage(): Promise { const stats = await fetchServerStats() + if (!stats.pipelines) { + return ( + + + Pipeline telemetry unavailable + + + The backend does not currently expose measured pipeline telemetry. No simulated engine, latency, accuracy, or capacity values are shown. + + + ) + } const { whisper, ollama, realtimeVoice, ragVector, meetingIntelligence } = stats.pipelines return ( @@ -42,7 +54,7 @@ export default async function PipelinesPage(): Promise { sx={{ fontFamily: FONT_SANS, fontSize: '18px', - fontWeight: 700, + fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0, @@ -63,7 +75,7 @@ export default async function PipelinesPage(): Promise { mt: 0.25, }} > - LOCAL WHISPER • OLLAMA V0.32.1 • GPT-REALTIME 2.1 • VECTOR RAG • DIARIZATION + Local Whisper, Ollama v0.32.1, GPT-Realtime 2.1, Vector RAG, Diarization @@ -103,7 +115,7 @@ export default async function PipelinesPage(): Promise { - + Faster-Whisper STT Sidecar @@ -119,19 +131,19 @@ export default async function PipelinesPage(): Promise { AVG LATENCY - + {whisper.avgLatencyMs}ms SPEEDUP FACTOR - + {whisper.speedupFactor} GPU VRAM - + {whisper.gpuVramUsage} @@ -156,7 +168,7 @@ export default async function PipelinesPage(): Promise { - + Bundled Ollama Runtime @@ -172,19 +184,19 @@ export default async function PipelinesPage(): Promise { THROUGHPUT - + {ollama.tokensPerSecond} tok/s CONTEXT LIMIT - + {ollama.activeContextLimit} VRAM OCCUPANCY - + {ollama.vramAllocated} @@ -212,7 +224,7 @@ export default async function PipelinesPage(): Promise { - + GPT-Realtime 2.1 Live Engine @@ -228,19 +240,19 @@ export default async function PipelinesPage(): Promise { LIVE STREAMS - + {realtimeVoice.activeStreams} Active AUDIO RTT - + {realtimeVoice.avgAudioRttMs}ms LOCAL FALLBACK - + {realtimeVoice.localFallbackRate} @@ -265,7 +277,7 @@ export default async function PipelinesPage(): Promise { - + SQLite Vector RAG Engine @@ -281,19 +293,19 @@ export default async function PipelinesPage(): Promise { INDEXED DOCS - + {ragVector.indexedDocuments.toLocaleString()} VECTOR CHUNKS - + {ragVector.totalVectorChunks.toLocaleString()} SEARCH HIT RATE - + {ragVector.topHitRatePercent}% @@ -319,7 +331,7 @@ export default async function PipelinesPage(): Promise { - + Meeting Intelligence & Speaker Diarization (Phase 14~15.5) @@ -335,7 +347,7 @@ export default async function PipelinesPage(): Promise { SPEAKER ACCURACY - + {meetingIntelligence.speakerAccuracyPercent}% @@ -344,7 +356,7 @@ export default async function PipelinesPage(): Promise { ACTIVE MEETINGS - + {meetingIntelligence.activeMeetingSessions} Live @@ -353,7 +365,7 @@ export default async function PipelinesPage(): Promise { TEMPLATES TODAY - + {meetingIntelligence.templatesGeneratedToday} Docs @@ -362,7 +374,7 @@ export default async function PipelinesPage(): Promise { MINDMAP EXPORTS - + {meetingIntelligence.mindmapsExported} Maps diff --git a/apps/admin/src/app/(admin)/releases/page.tsx b/apps/admin/src/app/(admin)/releases/page.tsx index c389392..258619c 100644 --- a/apps/admin/src/app/(admin)/releases/page.tsx +++ b/apps/admin/src/app/(admin)/releases/page.tsx @@ -1,397 +1,353 @@ // apps/admin/src/app/(admin)/releases/page.tsx -// D3RO Voice Admin CRM — Release & Distribution Management Hub +// D3RO Voice Admin CRM — Release & Distribution Hub (Forgejo live feed) -'use client' +import { Box, Typography, Button } from '@mui/material' +import Link from 'next/link' +import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds' +import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme' +import { requireManager } from '@/lib/admin-guard' +import { fetchReleaseHub, type ReleaseAssetPlatform, type ReleaseHub } from '@/lib/forgejo-releases' +import { ChecksumCopy } from '@/components/checksum-copy' -import React, { useState } from 'react' -import { Box, Typography, Button, LinearProgress, Switch } from '@mui/material' -import { - CheckCircle2, - Copy, - ExternalLink, - ArrowUpRight, -} from 'lucide-react' -import { C, FONT_SANS, FONT_MONO } from '@/lib/console-theme' +export const dynamic = 'force-dynamic' -interface ReleaseAsset { - id: string - name: string - os: 'Windows' | 'macOS' | 'Linux' | 'Feed' - version: string - sizeMb: number - downloads: number - sha256: string - status: 'active' | 'deprecated' | 'archived' - url: string +const PLATFORM_LABEL: Record = { + windows: 'Windows', + macos: 'macOS', + android: 'Android', + linux: 'Linux', + feed: 'Update Feed', + other: 'Other' } -const INITIAL_ASSETS: ReleaseAsset[] = [ - { - id: '1', - name: 'D3RO-Voice-Setup-1.0.0-x64.exe', - os: 'Windows', - version: '1.0.0', - sizeMb: 102.1, - downloads: 1420, - sha256: 'b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2', - status: 'active', - url: '/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe', - }, - { - id: '2', - name: 'D3RO-Voice-1.0.0-arm64.dmg', - os: 'macOS', - version: '1.0.0', - sizeMb: 98.4, - downloads: 890, - sha256: 'd9f28a391c49b1a03982e0192847192837491823749182374918237491823749', - status: 'active', - url: '/releases/1.0.0/D3RO-Voice-1.0.0-arm64.dmg', - }, - { - id: '3', - name: 'latest.yml', - os: 'Feed', - version: '1.0.0', - sizeMb: 0.01, - downloads: 4820, - sha256: '795b7230bec047284785f480026d51b9247d8618e083d88cfda786d1894ca367', - status: 'active', - url: '/releases/latest.yml', - }, - { - id: '4', - name: 'D3RO-Voice-Setup-0.2.1-alpha-x64.exe', - os: 'Windows', - version: '0.2.1-alpha', - sizeMb: 99.8, - downloads: 620, - sha256: '1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b', - status: 'deprecated', - url: '/releases/0.2.1-alpha/D3RO-Voice-Setup-0.2.1-alpha-x64.exe', - }, -] +const PLATFORM_BADGE: Record = { + windows: 'blue', + macos: 'purple', + android: 'green', + linux: 'orange', + feed: 'cyan', + other: 'orange' +} -export default function ReleasesManagementPage(): React.ReactElement { - const [assets] = useState(INITIAL_ASSETS) - const [rolloutPercent, setRolloutPercent] = useState(100) - const [forceUpdateEnabled, setForceUpdateEnabled] = useState(false) - const [copiedId, setCopiedId] = useState(null) +function formatSize(sizeBytes: number): string { + if (sizeBytes <= 0) return '—' + if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB` + return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB` +} - const copyToClipboard = (text: string, id: string): void => { - navigator.clipboard.writeText(text).then(() => { - setCopiedId(id) - setTimeout(() => setCopiedId(null), 2000) - }) - } +function formatDate(value: string): string { + if (!value) return '—' + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? '—' : parsed.toISOString().slice(0, 10) +} - const totalDownloads = assets.reduce((acc, curr) => acc + curr.downloads, 0) +function HeaderBar({ feedLive, repoHtmlUrl }: { feedLive: boolean; repoHtmlUrl: string }): React.ReactElement { + return ( + + + + + + + Release & Distribution Hub + + {feedLive ? ( + + LIVE FORGEJO FEED + + ) : ( + + FEED UNREACHABLE + + )} + + + Desktop & mobile installers, SHA-256 integrity, download telemetry + + + + + + + + + + ) +} + +function KpiCards({ hub }: { hub: ReleaseHub }): React.ReactElement { + const latest = hub.latestStable + const latestPlatforms = [...new Set(latest?.assets.map((asset) => asset.platform) ?? [])].filter( + (platform) => platform !== 'other' + ) + const cards = [ + { + title: 'Total Asset Downloads', + value: hub.totalDownloads.toLocaleString(), + subtext: 'Forgejo attachment download counters' + }, + { + title: 'Latest Stable Release', + value: latest?.tagName ?? '—', + subtext: latest ? `Published ${formatDate(latest.publishedAt)}` : 'No stable release published' + }, + { + title: 'Published Releases', + value: hub.releases.length.toLocaleString(), + subtext: `${hub.prereleaseCount.toLocaleString()} pre-release channel builds` + }, + { + title: 'Latest Platform Coverage', + value: latestPlatforms.length ? latestPlatforms.map((platform) => PLATFORM_LABEL[platform]).join(' · ') : '—', + subtext: latest ? `${latest.assets.length.toLocaleString()} packaged artifacts` : 'Awaiting first artifact upload' + } + ] return ( - - {/* Header */} - - - - - Release & Distribution Hub - - - v1.0.0 STABLE - - - - Manage desktop installer distribution, multi-platform binaries, SHA-256 integrity checks, and auto-update feeds. - - - - - - - + {card.title} + + + {card.value} + + {card.subtext} + ))} + + ) +} + +function LatestAssetsPanel({ latest }: { latest: ReleaseHub['latestStable'] }): React.ReactElement { + return ( + + + + Latest Binary Packages & Checksums + + {latest && ( + {latest.tagName} + )} - {/* KPI Cards */} - - - - Total App Downloads - - - {totalDownloads.toLocaleString()} - - - +18.4% WoW (Free & Pro Installs) - - - - - - Auto-Update Feed Health - - - - - 200 OK (Live) - - - - latest.yml • generic feed - - - - - - Synology NAS Storage - - - 2.4 GB / 8 TB - - - /volume1/docker/d3ro-voice - - - - - - Active Release Version - - - 1.0.0 - - - Deployed: 2026-08-20 - - - - - {/* Phased Rollout & Control Card */} - - - - - Phased Rollout & Auto-Update Policy - - - Control automatic background update delivery to client desktop installations. - - - - - - - Force Update: - - setForceUpdateEnabled(e.target.checked)} - size="small" - /> - - - - Rollout: {rolloutPercent}% - - - - - - - - - - {[10, 25, 50, 100].map((pct) => ( - - ))} - - - - {/* Release Assets Table */} - - - - Published Binary Packages & Checksums - - - + {!latest || latest.assets.length === 0 ? ( + + 최신 릴리스에 업로드된 아티팩트가 없습니다. + + ) : ( - + - - Artifact Name - Platform - Version - Size - Downloads - SHA-256 Checksum - Status + + Artifact + Platform + Size + Downloads + SHA-256 + Link - - {assets.map((asset) => ( - - + {latest.assets.map((asset) => ( + + {asset.name} - - {asset.os} + + {PLATFORM_LABEL[asset.platform]} - - {asset.version} + {formatSize(asset.sizeBytes)} + + {asset.downloadCount.toLocaleString()} - - {asset.sizeMb} MB + + {asset.sha256 ? ( + + ) : ( + + not published + + )} - - {asset.downloads.toLocaleString()} - - - - - {asset.sha256} - - copyToClipboard(asset.sha256, asset.id)} - sx={{ cursor: 'pointer', color: copiedId === asset.id ? '#34d399' : C.cyanLight, '&:hover': { color: '#fff' } }} - > - {copiedId === asset.id ? : } - - - - - + - {asset.status} - + Download ↗ + ))} + )} + + ) +} + +function ReleaseHistoryPanel({ hub }: { hub: ReleaseHub }): React.ReactElement { + return ( + + + Release Channel History + + {hub.releases.length === 0 ? ( + 발행된 릴리스가 없습니다. + ) : ( + + + + + Tag + Release + Published + Channel + Assets + Downloads + Source + + + + {hub.releases.map((release) => ( + + + {release.tagName} + + + {release.name} + + {formatDate(release.publishedAt)} + + + {release.isPrerelease ? 'PRE-RELEASE' : 'STABLE'} + + + + {release.assets.length.toLocaleString()} + + + {release.downloadCount.toLocaleString()} + + + + Forgejo ↗ + + + + ))} + + + + )} + + ) +} + +export default async function ReleasesManagementPage(): Promise { + await requireManager() + + let hub: ReleaseHub | null = null + let feedError: string | null = null + try { + hub = await fetchReleaseHub() + } catch (error) { + feedError = error instanceof Error ? error.message : 'Unknown release feed error' + } + + if (!hub) { + return ( + + + + + Forgejo 릴리스 피드에 연결하지 못했습니다. + + {feedError} + + 네트워크 상태를 확인하거나 RELEASE_REPO_URL 환경변수를 점검해주세요. 샘플 수치는 표시하지 않습니다. + + + ) + } + + return ( + + + + + ) } diff --git a/apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx b/apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx index e842706..ccdfb7c 100644 --- a/apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx +++ b/apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx @@ -4,7 +4,7 @@ // 구독 수정/삭제 클라이언트 컴포넌트 import { useState } from 'react' -import { Box, Button } from '@mui/material' +import { Alert, Box, Button } from '@mui/material' import { d3roFontMono } from '@d3ro/ui/theme' import { SubscriptionForm } from '@/components/subscription-form' import { MemoDialog } from '@/components/memo-dialog' @@ -36,9 +36,11 @@ export function SubscriptionDetailClient({ const router = useRouter() const [deleteOpen, setDeleteOpen] = useState(false) const [deleteLoading, setDeleteLoading] = useState(false) + const [deleteError, setDeleteError] = useState(null) const handleDelete = async (memo: string): Promise => { setDeleteLoading(true) + setDeleteError(null) try { await callAdminApi(`admin-subscriptions?userId=${userId}`, { method: 'DELETE', @@ -46,8 +48,8 @@ export function SubscriptionDetailClient({ }) setDeleteOpen(false) router.refresh() - } catch { - // error handled in dialog + } catch (error) { + setDeleteError(error instanceof Error ? error.message : 'Subscription deletion failed') } finally { setDeleteLoading(false) } @@ -74,6 +76,7 @@ export function SubscriptionDetailClient({ {/* admin 이상: 삭제 가능 */} {canCreateDelete && ( <> + {deleteError && {deleteError}} - + {hasMinRole(admin, 'super_admin') && } + {hasMinRole(admin, 'admin') && ( + + + + )} @@ -163,51 +176,51 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps - Monthly Recurring (MRR) + Total Subscription Records - $ + # - - ${serverStats.mrrUsd.toLocaleString()} + + {subs.length.toLocaleString()} - ↑ 18.4% vs last month + Persisted in Supabase - Paid Subscribers (Pro/Pro+) + Active Subscriptions - VIP + ON - - {(serverStats.tierDistribution.pro + serverStats.tierDistribution.pro_plus).toLocaleString()} Paid + + {subs.filter((subscription) => subscription.status === 'active').length.toLocaleString()} - {serverStats.tierDistribution.pro_plus} Pro+ • {serverStats.tierDistribution.pro} Pro + Current status = active - Renewal Success Rate + Renewal Failures - % + % - - 99.4% + + {subs.reduce((total, subscription) => total + subscription.renewal_failures, 0).toLocaleString()} - 0.6% Churn • Fast Retry System + Persisted retry failures @@ -215,8 +228,8 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps {/* Subscription Table */} - - Active Subscription Contracts + + Subscription Contracts SHOWING {filteredSubs.length} RECORDS @@ -230,7 +243,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps SUBSCRIBER TIER STATUS - MRR VALUE + RENEWAL FAILURES PROVIDER EXPIRES / RENEWS ACTION @@ -269,14 +282,14 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps {s.status.toUpperCase()} - - ${s.mrrAmount}/mo + + {s.renewal_failures} {s.payment_provider} - {s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : 'Free Tier'} + {s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : 'Not set'} diff --git a/apps/admin/src/app/(admin)/support/page.tsx b/apps/admin/src/app/(admin)/support/page.tsx index 6f29177..76ce8e4 100644 --- a/apps/admin/src/app/(admin)/support/page.tsx +++ b/apps/admin/src/app/(admin)/support/page.tsx @@ -1,315 +1,11 @@ -// apps/admin/src/app/(admin)/support/page.tsx -// D3RO Voice — Customer Support (CA/CS) & Diagnostics Console (Midnight Glass v2) - -import { Box, Typography, Button } from '@mui/material' -import { - C, - FONT_SANS, - FONT_MONO, - panelSx, - tableSx, - statusBadgeSx, - primaryButtonSx, -} from '@/lib/console-theme' -import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds' - -interface TicketRow { - id: string - customerEmail: string - category: string - priority: 'urgent' | 'high' | 'normal' - status: 'open' | 'in_progress' | 'resolved' - subject: string - createdAt: string - slaRemaining: string - machineId: string - gpuAccelerated: boolean - audioDevice: string - aiSuggestedFix: string -} - -export default async function AdminSupportPage(): Promise { - const tickets: TicketRow[] = [ - { - id: 'TCK-9401', - customerEmail: 'minho.park@linecorp.com', - category: 'CUDA GPU / VRAM', - priority: 'urgent', - status: 'open', - subject: 'CUDA out of memory error during 2-hour long meeting mode transcription', - createdAt: '12m ago', - slaRemaining: '18m left', - machineId: 'win-rtx3080-99af', - gpuAccelerated: true, - audioDevice: 'Yamaha AG03 USB Audio', - aiSuggestedFix: 'Recommend switching model to large-v3-turbo with int8 quantization and enabling 10-minute auto-chunking.', - }, - { - id: 'TCK-9402', - customerEmail: 'tax_admin@krafton.com', - category: 'Billing / Tax Invoice', - priority: 'normal', - status: 'open', - subject: '법인 정기구독 전자세금계산서 사업자등록번호 변경 요청', - createdAt: '45m ago', - slaRemaining: '3h 15m left', - machineId: 'mac-m3max-01bc', - gpuAccelerated: false, - audioDevice: 'Built-in Microphone', - aiSuggestedFix: 'Verify business registration certificate on NTS HomeTax and re-issue Toss Payments tax invoice automatically.', - }, - { - id: 'TCK-9403', - customerEmail: 'sarah.k@designstudio.io', - category: 'Audio Hardware / STT', - priority: 'high', - status: 'in_progress', - subject: 'Microphone permission denied after Windows 11 24H2 update', - createdAt: '1h 10m ago', - slaRemaining: '45m left', - machineId: 'win-thinkpad-44a1', - gpuAccelerated: false, - audioDevice: 'Realtek High Definition Audio', - aiSuggestedFix: 'Guide user to Windows Settings > Privacy & Security > Microphone > Let desktop apps access your microphone.', - }, - { - id: 'TCK-9404', - customerEmail: 'alex.chen@cursor.sh', - category: 'Feature / Prompt', - priority: 'normal', - status: 'resolved', - subject: 'Request custom dictionary sync via CLI webhook', - createdAt: '5h ago', - slaRemaining: 'Met SLA (24m)', - machineId: 'linux-popos-77e2', - gpuAccelerated: true, - audioDevice: 'Shure SM7B + Scarlett Solo', - aiSuggestedFix: 'Provided OpenAPI documentation for /api/dictionary/sync and token authentication header guide.', - }, - ] +import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel' +export default function AdminSupportPage(): React.ReactElement { return ( - <> - {/* Header */} - - - - - Customer Support (CA/CS) & Diagnostics Desk - - - 4 OPEN TICKETS - - - - AI-first customer triage, live hardware telemetry inspector, automated 7-day refund verification. - - - - - - - - - {/* Support KPI Metrics */} - - - - - Pending Tickets - - - 4 / 180 total - - - 1 Urgent Ticket - - - - - - - - First Response Time - - - 4.2 min - - - 99.4% SLA Compliance - - - - - - - - AI Auto-Resolution Rate - - - 78.5% - - - 141 resolved by AI Bot - - - - - - - - CSAT Satisfaction Score - - - 4.92 / 5.0 - - - Based on 92 ratings - - - - - - {/* Tickets Queue Table */} - - - - Incoming Ticket Queue & Diagnostic Payloads - - - Real-time Channel.io Webhook Active - - - - - - - - Ticket ID - Customer / User - Category - Subject & Issue - Priority - SLA Timer - Hardware / Telemetry - Status - - - - {tickets.map((t) => ( - - - - {t.id} - - - {t.createdAt} - - - - - {t.customerEmail} - - - - - {t.category} - - - - - {t.subject} - - - 💡 AI: {t.aiSuggestedFix} - - - - - {t.priority.toUpperCase()} - - - - - {t.slaRemaining} - - - - - {t.audioDevice} - - - {t.machineId} • GPU: {t.gpuAccelerated ? 'ON' : 'OFF'} - - - - - {t.status.toUpperCase()} - - - - ))} - - - - - + ) } diff --git a/apps/admin/src/app/(admin)/usage/page.tsx b/apps/admin/src/app/(admin)/usage/page.tsx index 220e482..d8af0fd 100644 --- a/apps/admin/src/app/(admin)/usage/page.tsx +++ b/apps/admin/src/app/(admin)/usage/page.tsx @@ -94,7 +94,7 @@ export default async function AdminUsagePage(): Promise { sx={{ fontFamily: FONT_SANS, fontSize: '20px', - fontWeight: 700, + fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0, @@ -115,7 +115,7 @@ export default async function AdminUsagePage(): Promise { mt: 0.25, }} > - REALTIME STT AUDIO MINUTES • LLM TOKEN COUNTER • CLOUD PROVIDER ATTRIBUTION + Realtime STT audio minutes, LLM token counter, cloud provider attribution @@ -141,7 +141,7 @@ export default async function AdminUsagePage(): Promise { {m.icon} - + {m.value} @@ -154,7 +154,7 @@ export default async function AdminUsagePage(): Promise { {/* STT Provider Usage Breakdown Table */} - + 🎙️ Speech-to-Text (STT) Transcription Metrics by Provider @@ -194,7 +194,7 @@ export default async function AdminUsagePage(): Promise { {p.avgLatencyMs}ms - + ${p.totalCost.toFixed(4)} @@ -206,7 +206,7 @@ export default async function AdminUsagePage(): Promise { {/* Feature Token Distribution Visual Progress Bars */} - + Token & Compute Distribution by Feature @@ -240,7 +240,7 @@ export default async function AdminUsagePage(): Promise { {/* User Breakdown Table */} - + Cost Attribution by Top Power Users @@ -274,7 +274,7 @@ export default async function AdminUsagePage(): Promise { {u.totalTokens.toLocaleString()} - + ${u.totalCost.toFixed(6)} @@ -287,7 +287,7 @@ export default async function AdminUsagePage(): Promise { {/* LLM Model Breakdown Table */} - + 🧠 Cost & Token Breakdown by LLM Model Engine @@ -321,7 +321,7 @@ export default async function AdminUsagePage(): Promise { {m.totalTokens.toLocaleString()} - + ${m.totalCost.toFixed(6)} diff --git a/apps/admin/src/app/(admin)/users/[id]/page.tsx b/apps/admin/src/app/(admin)/users/[id]/page.tsx index d5ef493..db2634d 100644 --- a/apps/admin/src/app/(admin)/users/[id]/page.tsx +++ b/apps/admin/src/app/(admin)/users/[id]/page.tsx @@ -4,63 +4,60 @@ import { Box, Typography } from '@mui/material' import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme' import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds' -import { getSupabaseServerClient } from '@/lib/supabase-server' +import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin' +import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel' import { requireManager, isAdmin } from '@/lib/admin-guard' import Link from 'next/link' +import { notFound } from 'next/navigation' import { RoleChangeButton } from './role-change-button' import { PaymentHistory } from '@/components/payment-history' -import { fetchUsers } from '@/lib/api-server' interface PageProps { params: Promise<{ id: string }> } export default async function AdminUserDetailPage({ params }: PageProps): Promise { + if (!isSupabaseAdminConfigured()) { + return ( + + ) + } const { id } = await params const admin = await requireManager() - const supabase = await getSupabaseServerClient() + const supabase = await getSupabaseAdminClient() - const [profileRes, subRes, usageRes] = await Promise.all([ - supabase.from('profiles').select('*').eq('id', id).maybeSingle(), - supabase.from('subscriptions').select('*').eq('user_id', id).maybeSingle(), - supabase.from('daily_usage').select('*') + const [authRes, profileRes, subRes, usageRes] = await Promise.all([ + supabase.auth.admin.getUserById(id), + supabase.from('profiles').select('id, name, locale, tier, role, created_at, updated_at').eq('id', id).maybeSingle(), + supabase.from('subscriptions').select('id, user_id, tier, status, provider, payment_provider, current_period_start, current_period_end, overage_credits, admin_note, cancel_at, created_at, updated_at').eq('user_id', id).maybeSingle(), + supabase.from('daily_usage').select('date, feature, count') .eq('user_id', id) .gte('date', new Date(Date.now() - 30 * 86400000).toISOString().split('T')[0]) .order('date', { ascending: false }), ]) - // If supabase profile not found, fallback to rich mock user dataset - const allUsers = await fetchUsers() - const matchedMock = allUsers.find((u) => String(u.id) === id || u.uid === id) || allUsers[0] + if (authRes.error) throw new Error(`Supabase auth user failed: ${authRes.error.message}`) + if (profileRes.error) throw new Error(`Supabase profile failed: ${profileRes.error.message}`) + if (subRes.error) throw new Error(`Supabase subscription failed: ${subRes.error.message}`) + if (usageRes.error) throw new Error(`Supabase usage failed: ${usageRes.error.message}`) + if (!authRes.data.user || !profileRes.data) notFound() - const profile = profileRes.data || { - id: matchedMock.uid, - name: matchedMock.name, - email: matchedMock.email, - tier: matchedMock.tier, - role: matchedMock.role, - locale: 'ko-KR', - created_at: matchedMock.createdAt, - last_login: matchedMock.lastLoginAt, - last_device: matchedMock.lastActiveDevice, + const authUser = authRes.data.user + const profile = { + ...profileRes.data, + email: authUser.email ?? null, + last_login: authUser.last_sign_in_at ?? null, } + const sub = subRes.data + const usage = usageRes.data ?? [] - const sub = subRes.data || { - status: 'active', - payment_provider: 'LemonSqueezy', - current_period_end: '2026-12-31T23:59:59Z', - cancel_at: null, - } - - const usage = (usageRes.data && usageRes.data.length > 0) ? usageRes.data : [ - { date: '2026-08-19', feature: 'Realtime Dictation (Whisper Turbo)', count: 42 }, - { date: '2026-08-19', feature: 'Meeting Mode + Multi-Doc', count: 4 }, - { date: '2026-08-18', feature: 'Speaker Diarization (Pyannote)', count: 12 }, - { date: '2026-08-18', feature: 'SQLite Vector RAG Query', count: 18 }, - { date: '2026-08-17', feature: 'Auto Polish & Refine', count: 35 }, - ] - - const tier = (profile.tier as string) ?? 'free' + const tier = typeof profile.tier === 'string' && ['free', 'pro', 'pro_plus'].includes(profile.tier) + ? profile.tier + : null const isProPlus = tier === 'pro_plus' const isPro = tier === 'pro' const userRole = ((profile.role as string) ?? 'user') as 'user' | 'manager' | 'admin' | 'super_admin' @@ -97,7 +94,7 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis sx={{ fontFamily: FONT_SANS, fontSize: '20px', - fontWeight: 700, + fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0, @@ -106,7 +103,7 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis User Profile Console - {isProPlus ? 'PRO+ VIP' : tier.toUpperCase()} + {isProPlus ? 'PRO+ VIP' : tier ? tier.toUpperCase() : 'TIER UNAVAILABLE'} @@ -147,19 +144,19 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis alignItems: 'center', justifyContent: 'center', color: '#ffffff', - fontWeight: 700, + fontWeight: 500, fontSize: '20px', boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)', }} > - {((profile.name as string) || 'U').charAt(0)} + {((profile.name as string) || (profile.email as string) || '?').charAt(0).toUpperCase()} - - {(profile.name as string) || 'D3RO User'} + + {(profile.name as string) || 'Name not provided'} - {(profile.email as string) || 'user@d3ro.voice'} + {(profile.email as string) || 'Email unavailable'} @@ -167,51 +164,60 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis - - + - + {/* Subscription & Quota Card */} - + Subscription & Quota Entitlements - - {((sub.status as string) || 'ACTIVE').toUpperCase()} + + {sub?.status ? String(sub.status).toUpperCase() : 'NO SUBSCRIPTION'} - - - - + + + + - Phase 1~15.5 Enabled Features + Entitlement source - Whisper Turbo STT (Unlimited) - Ollama NDJSON Stream - {isProPlus && GPT-Realtime 2.1 Live Voice} - Meeting Summary & Multi-Doc - SQLite Vector RAG - {isProPlus && Pyannote Diarization} + {sub ? 'SUPABASE SUBSCRIPTION' : 'NO ACTIVE CONTRACT RECORD'} + {/* Tier-based Enabled Features */} + + + Enabled Features + + + Whisper Turbo STT (Unlimited) + Ollama NDJSON Stream + {isProPlus && GPT-Realtime 2.1 Live Voice} + Meeting Summary & Multi-Doc + SQLite Vector RAG + {isProPlus && Pyannote Diarization} + + + {/* 30-Day Activity Heatmap Table */} - + 30-Day Feature Execution Telemetry @@ -230,20 +236,26 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis - {usage.map((row, i) => ( - + {usage.length === 0 ? ( + + + No measured usage in the last 30 days. + + + ) : usage.map((row) => ( + {row.date as string} {row.feature as string} - + {row.count as number} calls - SUCCESS + MEASURED @@ -256,11 +268,11 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis {/* Payment History */} - + Payment History & Invoices - LEMONSQUEEZY VERIFIED + PAYMENT DATA diff --git a/apps/admin/src/app/(admin)/users/page.tsx b/apps/admin/src/app/(admin)/users/page.tsx index fc4100b..66afb0d 100644 --- a/apps/admin/src/app/(admin)/users/page.tsx +++ b/apps/admin/src/app/(admin)/users/page.tsx @@ -3,7 +3,8 @@ import React from 'react' import { Box, Typography, Button } from '@mui/material' -import { fetchUsers } from '@/lib/api-server' +import { fetchProductUsers, isSupabaseAdminConfigured } from '@/lib/supabase-admin' +import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel' import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, filterBtnSx, statusBadgeSx } from '@/lib/console-theme' import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds' import Link from 'next/link' @@ -13,12 +14,25 @@ interface PageProps { } export default async function AdminUsersPage({ searchParams }: PageProps): Promise { + if (!isSupabaseAdminConfigured()) { + return ( + + ) + } const params = await searchParams const tierFilter = params.tier ?? 'all' const roleFilter = params.role ?? 'all' - const searchQuery = (params.q ?? '').toLowerCase() + if (!['all', 'free', 'pro', 'pro_plus'].includes(tierFilter)) throw new Error('Invalid user tier filter') + if (!['all', 'user', 'manager', 'admin', 'super_admin'].includes(roleFilter)) throw new Error('Invalid user role filter') + const rawSearchQuery = params.q ?? '' + if (rawSearchQuery.length > 100) throw new Error('User search query is too long') + const searchQuery = rawSearchQuery.trim().toLowerCase() - const allUsers = await fetchUsers() + const allUsers = await fetchProductUsers() const filteredUsers = allUsers.filter((u) => { if (tierFilter !== 'all' && u.tier !== tierFilter) return false @@ -64,7 +78,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi sx={{ fontFamily: FONT_SANS, fontSize: '20px', - fontWeight: 700, + fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0, @@ -85,7 +99,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi mt: 0.25, }} > - MULTI-TIER QUOTAS • HARDWARE SESSIONS • ROLES & ACCESS CONTROL + Supabase auth accounts, subscription tiers, roles and access control @@ -105,8 +119,8 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi {/* Main Table Card */} - - User Account Profiles & Quota Consumption + + User Account Profiles SHOWING {filteredUsers.length} OF {allUsers.length} USERS @@ -120,8 +134,8 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi USER PROFILE TIER ROLE - DAILY QUOTA STATUS - LAST ACTIVE PLATFORM + USAGE + LAST SIGN-IN STATUS ACTION @@ -157,7 +171,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi alignItems: 'center', justifyContent: 'center', color: '#ffffff', - fontWeight: 700, + fontWeight: 500, fontSize: '13px', boxShadow: isProPlus ? '0 0 12px rgba(168, 85, 247, 0.4)' : 'none', }} @@ -169,7 +183,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi {u.name} - {u.email} + {u.email || 'Email not set'} @@ -179,7 +193,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi {/* Tier Badge */} - {isProPlus ? 'PRO+ VIP' : u.tier.toUpperCase()} + {isProPlus ? 'PRO+ VIP' : u.tier?.toUpperCase() ?? 'UNASSIGNED'} @@ -192,25 +206,14 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi {/* Daily Quota Status */} - - - Dictations: - - {u.dailyUsage.dictations} / {u.dailyUsage.dictationsMax === 9999 ? '∞' : u.dailyUsage.dictationsMax} - - - - LLM Calls: - - {u.dailyUsage.llmCalls} / {u.dailyUsage.llmCallsMax === 9999 ? '∞' : u.dailyUsage.llmCallsMax} - - - + + Open user detail for measured usage + {/* Last Active Platform */} - {u.lastActiveDevice} + {u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : 'Never signed in'} {/* Status */} diff --git a/apps/admin/src/app/api/admin/backend/[...segments]/route.ts b/apps/admin/src/app/api/admin/backend/[...segments]/route.ts new file mode 100644 index 0000000..1645186 --- /dev/null +++ b/apps/admin/src/app/api/admin/backend/[...segments]/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from 'next/server' +import { AdminBackendError, fetchAdminBackend } from '@/lib/backend-session' +import type { AdminRole } from '@/lib/admin-session' + +export const runtime = 'nodejs' + +const READ_PATHS = [ + /^\/stats$/, + /^\/users$/, + /^\/endpoints$/, + /^\/stt-endpoints$/, + /^\/stt-usage$/, + /^\/usage$/ +] + +const MANAGER_POST_PATHS = [/^\/stt-endpoints\/\d+\/test$/] +const ADMIN_POST_PATHS = [ + /^\/endpoints$/, + /^\/stt-endpoints$/, + /^\/stt-endpoints\/\d+\/set-default$/, + /^\/stt-endpoints\/test-direct$/ +] +const ADMIN_MUTATION_PATHS = [/^\/endpoints\/\d+$/, /^\/stt-endpoints\/\d+$/] +const IDEMPOTENT_POST_PATHS = [/^\/endpoints$/, /^\/stt-endpoints$/, /^\/stt-endpoints\/\d+\/set-default$/] + +function pathFor(segments: string[]): string | null { + if (!segments.length || segments.some((segment) => !/^[A-Za-z0-9-]+$/.test(segment))) { + return null + } + return `/${segments.join('/')}` +} + +function requiredRole(method: string, path: string): AdminRole | null { + if (method === 'GET' && READ_PATHS.some((pattern) => pattern.test(path))) return 'manager' + if (method === 'POST' && MANAGER_POST_PATHS.some((pattern) => pattern.test(path))) return 'manager' + if (method === 'POST' && ADMIN_POST_PATHS.some((pattern) => pattern.test(path))) return 'admin' + if ((method === 'PUT' || method === 'DELETE') && ADMIN_MUTATION_PATHS.some((pattern) => pattern.test(path))) { + return 'admin' + } + return null +} + +function isSameOrigin(request: NextRequest): boolean { + const origin = request.headers.get('origin') + if (!origin) return request.headers.get('sec-fetch-site') !== 'cross-site' + try { + const requestOrigin = new URL(origin).origin + if (requestOrigin === request.nextUrl.origin) return true + + const forwardedProto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim().toLowerCase() + const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim() + ?? request.headers.get('host')?.trim() + if ((forwardedProto !== 'http' && forwardedProto !== 'https') || + !forwardedHost || !/^[A-Za-z0-9.:[\]-]+$/.test(forwardedHost)) { + return false + } + return requestOrigin === `${forwardedProto}://${forwardedHost}` + } catch { + return false + } +} + +async function proxy( + request: NextRequest, + context: { params: Promise<{ segments: string[] }> } +): Promise { + const { segments } = await context.params + const path = pathFor(segments) + const role = path ? requiredRole(request.method, path) : null + if (!path || !role) return NextResponse.json({ error: 'admin_route_not_allowed' }, { status: 404 }) + if (request.method !== 'GET' && !isSameOrigin(request)) { + return NextResponse.json({ error: 'cross_site_request_rejected' }, { status: 403 }) + } + + let body: string | undefined + if (request.method !== 'GET') { + body = await request.text() + if (new TextEncoder().encode(body).byteLength > 64 * 1024) { + return NextResponse.json({ error: 'request_too_large' }, { status: 413 }) + } + } + + const mutatesState = + (request.method === 'POST' && IDEMPOTENT_POST_PATHS.some((pattern) => pattern.test(path))) || + ((request.method === 'PUT' || request.method === 'DELETE') && ADMIN_MUTATION_PATHS.some((pattern) => pattern.test(path))) + const idempotencyKey = request.headers.get('idempotency-key')?.trim() ?? '' + if (mutatesState && !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(idempotencyKey)) { + return NextResponse.json({ error: 'valid_idempotency_key_required' }, { status: 400 }) + } + + try { + const backend = await fetchAdminBackend(`${path}${request.nextUrl.search}`, { + method: request.method, + body, + headers: mutatesState ? { 'Idempotency-Key': idempotencyKey } : undefined + }, role) + const text = await backend.text() + return new NextResponse(text || null, { + status: backend.status, + headers: { + 'Content-Type': backend.headers.get('content-type') ?? 'application/json', + 'Cache-Control': 'no-store' + } + }) + } catch (error) { + if (error instanceof AdminBackendError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + return NextResponse.json({ error: 'admin_backend_unavailable' }, { status: 503 }) + } +} + +export const GET = proxy +export const POST = proxy +export const PUT = proxy +export const DELETE = proxy diff --git a/apps/admin/src/app/api/admin/license/route.ts b/apps/admin/src/app/api/admin/license/route.ts new file mode 100644 index 0000000..fb446e7 --- /dev/null +++ b/apps/admin/src/app/api/admin/license/route.ts @@ -0,0 +1,157 @@ +// apps/admin/src/app/api/admin/license/route.ts +// Ed25519 라이선스 발급 — 서명 개인키는 서버 환경변수로만 공급한다. + +import { randomUUID } from 'node:crypto' +import { NextRequest, NextResponse } from 'next/server' +import { + issueSignedLicenseKey, + DEFAULT_LICENSE_PRIVATE_KEY, + type SignedLicensePayload +} from '@d3ro/core/utils/crypto-license' +import type { LicenseTier } from '@d3ro/core/types' +import { AdminBackendError, fetchAdminBackend, requireVerifiedBackendSession } from '@/lib/backend-session' + +export const runtime = 'nodejs' + +const ISSUABLE_TIERS: readonly LicenseTier[] = ['pro', 'pro_plus', 'team', 'enterprise'] +const VALIDITY_MS: Record = { + '30d': 30 * 24 * 60 * 60 * 1_000, + '365d': 365 * 24 * 60 * 60 * 1_000, + lifetime: null +} +const MAX_DEVICES: Record = { + free: 1, + pro: 3, + pro_plus: 5, + team: 25, + enterprise: 999 +} + +interface SigningKey { + privateKeyPem: string + usedDefaultKey: boolean +} + +// 저장소에 포함된 기본 키쌍은 공개된 것이므로 위조 방어력이 없다. 운영 발급은 +// ADMIN_LICENSE_PRIVATE_KEY(전용 키 로테이션)로만 하고, 기본 키 사용은 명시적 opt-in. +function resolveSigningKey(): SigningKey | null { + const configured = process.env.ADMIN_LICENSE_PRIVATE_KEY?.trim() + if (configured) { + return { privateKeyPem: configured.replace(/\\n/g, '\n'), usedDefaultKey: false } + } + if (process.env.ADMIN_LICENSE_ALLOW_DEFAULT_KEY === 'true') { + return { privateKeyPem: DEFAULT_LICENSE_PRIVATE_KEY, usedDefaultKey: true } + } + return null +} + +function isSameOrigin(request: NextRequest): boolean { + const origin = request.headers.get('origin') + if (!origin) return request.headers.get('sec-fetch-site') !== 'cross-site' + try { + return new URL(origin).origin === request.nextUrl.origin + } catch { + return false + } +} + +export async function POST(request: NextRequest): Promise { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: 'cross_site_request_rejected' }, { status: 403 }) + } + + try { + await requireVerifiedBackendSession('super_admin') + } catch (error) { + const status = error instanceof AdminBackendError ? error.status : 401 + const message = error instanceof AdminBackendError ? error.message : 'admin_session_invalid' + return NextResponse.json({ error: message }, { status }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'invalid_request' }, { status: 400 }) + } + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return NextResponse.json({ error: 'invalid_request' }, { status: 400 }) + } + + const candidate = body as Record + const customerEmail = typeof candidate.customerEmail === 'string' ? candidate.customerEmail.trim().toLowerCase() : '' + const tier = candidate.tier + const validity = typeof candidate.validity === 'string' ? candidate.validity : '' + const machineId = typeof candidate.machineId === 'string' ? candidate.machineId.trim() : '' + const teamId = typeof candidate.teamId === 'string' ? candidate.teamId.trim() : '' + + if (!customerEmail || customerEmail.length > 150 || !customerEmail.includes('@')) { + return NextResponse.json({ error: 'invalid_customer_email' }, { status: 400 }) + } + if (typeof tier !== 'string' || !ISSUABLE_TIERS.includes(tier as LicenseTier)) { + return NextResponse.json({ error: 'invalid_tier' }, { status: 400 }) + } + if (!(validity in VALIDITY_MS)) { + return NextResponse.json({ error: 'invalid_validity' }, { status: 400 }) + } + if (machineId.length > 128 || teamId.length > 64) { + return NextResponse.json({ error: 'invalid_request' }, { status: 400 }) + } + + const signingKey = resolveSigningKey() + if (!signingKey) { + return NextResponse.json({ error: 'license_signing_unavailable' }, { status: 503 }) + } + + const issuedTier = tier as LicenseTier + const now = Date.now() + const validityMs = VALIDITY_MS[validity] + const payload: SignedLicensePayload = { + licenseId: `lic-${randomUUID()}`, + tier: issuedTier, + customerEmail, + issuedAt: now, + expiresAt: validityMs === null ? null : now + validityMs, + machineId: machineId || null, + ...(teamId ? { teamId } : {}), + maxDevices: MAX_DEVICES[issuedTier] + } + + let licenseKey: string + try { + licenseKey = issueSignedLicenseKey(payload, signingKey.privateKeyPem) + } catch { + return NextResponse.json({ error: 'license_signing_failed' }, { status: 500 }) + } + + // 발급 감사 기록(best-effort). 감사가 실패해도 이미 서명된 라이선스는 반환하되 상태를 알린다. + let auditRecorded = false + try { + const auditResponse = await fetchAdminBackend( + '/license-audit', + { + method: 'POST', + body: JSON.stringify({ + licenseId: payload.licenseId, + customerEmail: payload.customerEmail, + tier: payload.tier, + validity, + expiresAt: payload.expiresAt + }) + }, + 'super_admin' + ) + auditRecorded = auditResponse.ok + } catch { + auditRecorded = false + } + + return NextResponse.json({ + success: true, + licenseKey, + licenseId: payload.licenseId, + expiresAt: payload.expiresAt, + usedDefaultKey: signingKey.usedDefaultKey, + auditRecorded + }) +} diff --git a/apps/admin/src/app/api/admin/supabase/[operation]/route.ts b/apps/admin/src/app/api/admin/supabase/[operation]/route.ts new file mode 100644 index 0000000..1af74b6 --- /dev/null +++ b/apps/admin/src/app/api/admin/supabase/[operation]/route.ts @@ -0,0 +1,243 @@ +import { NextRequest, NextResponse } from 'next/server' +import { AdminBackendError, requireVerifiedBackendSession } from '@/lib/backend-session' +import { getSupabaseAdminClient } from '@/lib/supabase-admin' + +export const runtime = 'nodejs' + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const ROLES = new Set(['user', 'manager', 'admin', 'super_admin']) +const TIERS = new Set(['free', 'pro', 'pro_plus']) +const STATUSES = new Set(['active', 'canceled', 'past_due', 'expired']) + +function sameOrigin(request: NextRequest): boolean { + const origin = request.headers.get('origin') + if (!origin) return request.headers.get('sec-fetch-site') !== 'cross-site' + try { + const requestOrigin = new URL(origin).origin + if (requestOrigin === request.nextUrl.origin) return true + const forwardedProto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim().toLowerCase() + const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim() + ?? request.headers.get('host')?.trim() + if ((forwardedProto !== 'http' && forwardedProto !== 'https') || + !forwardedHost || !/^[A-Za-z0-9.:[\]-]+$/.test(forwardedHost)) { + return false + } + return requestOrigin === `${forwardedProto}://${forwardedHost}` + } catch { + return false + } +} + +async function bodyObject(request: NextRequest): Promise> { + if (request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') { + throw new AdminBackendError('application_json_required', 415) + } + const text = await request.text() + if (new TextEncoder().encode(text).byteLength > 32 * 1024) throw new AdminBackendError('request_too_large', 413) + try { + const parsed = JSON.parse(text) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error() + return parsed as Record + } catch { + throw new AdminBackendError('invalid_request', 400) + } +} + +function onlyKeys(body: Record, allowed: readonly string[]): void { + const keys = new Set(allowed) + if (Object.keys(body).some((key) => !keys.has(key))) { + throw new AdminBackendError('unexpected_request_field', 400) + } +} + +function onlyQuery(request: NextRequest, allowed: readonly string[]): void { + const keys = new Set(allowed) + for (const key of request.nextUrl.searchParams.keys()) { + if (!keys.has(key)) throw new AdminBackendError('unexpected_query_parameter', 400) + } +} + +function memo(value: unknown): string { + const normalized = typeof value === 'string' ? value.trim() : '' + if (normalized.length < 3 || normalized.length > 1000) { + throw new AdminBackendError('invalid_memo', 400) + } + return normalized +} + +function optionalDateTime(value: unknown): string | null { + if (value === undefined) return null + if (typeof value !== 'string' || value.length > 40 || !/^\d{4}-\d{2}-\d{2}T/.test(value)) { + throw new AdminBackendError('invalid_current_period_end', 400) + } + const parsed = Date.parse(value) + if (!Number.isFinite(parsed)) throw new AdminBackendError('invalid_current_period_end', 400) + return new Date(parsed).toISOString() +} + +function idempotencyKey(request: NextRequest): string { + const value = request.headers.get('idempotency-key')?.trim() ?? '' + if (!UUID.test(value)) throw new AdminBackendError('valid_idempotency_key_required', 400) + return value +} + +function uuid(value: unknown, field: string): string { + if (typeof value !== 'string' || !UUID.test(value)) throw new AdminBackendError(`${field}_invalid`, 400) + return value +} + +function rpcFailure(error: { code?: string | null; message?: string | null }): AdminBackendError { + const message = error.message ?? '' + if (/admin_identity_not_linked|insufficient_admin_role|super_admin_required|admin_role_required/i.test(message) || error.code === '42501') { + return new AdminBackendError('admin_forbidden', 403) + } + if (/target_user_not_found|subscription_not_found/i.test(message) || error.code === 'P0002') { + return new AdminBackendError('record_not_found', 404) + } + if (/already_exists|idempotency_key_reused|operation_in_progress|cannot_demote_last/i.test(message) || + error.code === '23505' || error.code === '23514' || error.code === '55P03') { + return new AdminBackendError('record_conflict', 409) + } + if (/^invalid_|memo_must_be|tier_required/i.test(message) || error.code === '22023') { + return new AdminBackendError('invalid_request', 400) + } + return new AdminBackendError('supabase_admin_operation_failed', 500) +} + +async function handleUsers(request: NextRequest): Promise { + if (request.method !== 'PATCH') return NextResponse.json({ error: 'method_not_allowed' }, { status: 405 }) + onlyQuery(request, []) + const session = await requireVerifiedBackendSession('admin') + const body = await bodyObject(request) + onlyKeys(body, ['userId', 'newRole', 'memo']) + const targetUserId = uuid(body.userId, 'user_id') + const newRole = typeof body.newRole === 'string' ? body.newRole : '' + if (!ROLES.has(newRole)) throw new AdminBackendError('role_invalid', 400) + const auditMemo = memo(body.memo) + const supabase = await getSupabaseAdminClient('admin') + const { data, error } = await supabase.rpc('admin_change_user_role_v1', { + p_actor_email: session.email, + p_idempotency_key: idempotencyKey(request), + p_target_user_id: targetUserId, + p_new_role: newRole, + p_memo: auditMemo + }) + if (error) throw rpcFailure(error) + if (!data || typeof data !== 'object' || Array.isArray(data) || + data.success !== true || data.userId !== targetUserId || data.newRole !== newRole) { + throw new AdminBackendError('invalid_admin_rpc_response', 502) + } + return NextResponse.json({ success: true, userId: targetUserId, newRole }, { headers: { 'Cache-Control': 'no-store' } }) +} + +async function handleSubscriptions(request: NextRequest): Promise { + if (!['POST', 'PATCH', 'DELETE'].includes(request.method)) { + return NextResponse.json({ error: 'method_not_allowed' }, { status: 405 }) + } + const minimumRole = request.method === 'PATCH' ? 'manager' : 'admin' + const session = await requireVerifiedBackendSession(minimumRole) + const body = await bodyObject(request) + const action = request.method === 'POST' ? 'create' : request.method === 'PATCH' ? 'update' : 'delete' + onlyQuery(request, action === 'create' ? [] : ['userId']) + onlyKeys(body, action === 'create' + ? ['userId', 'tier', 'status', 'currentPeriodEnd', 'overageCredits', 'adminNote', 'memo'] + : action === 'update' + ? ['tier', 'status', 'currentPeriodEnd', 'overageCredits', 'adminNote', 'memo'] + : ['memo']) + const requestedUserId = request.method === 'POST' ? body.userId : request.nextUrl.searchParams.get('userId') + const userId = uuid(requestedUserId, 'user_id') + const tier = typeof body.tier === 'string' ? body.tier : null + const status = typeof body.status === 'string' ? body.status : null + if (tier !== null && !TIERS.has(tier)) throw new AdminBackendError('tier_invalid', 400) + if (action === 'create' && tier === null) throw new AdminBackendError('tier_required', 400) + if (status !== null && !STATUSES.has(status)) throw new AdminBackendError('status_invalid', 400) + const overageCredits = body.overageCredits + if (overageCredits !== undefined && + (!Number.isInteger(overageCredits) || (overageCredits as number) < 0 || (overageCredits as number) > 1_000_000)) { + throw new AdminBackendError('overage_credits_invalid', 400) + } + const adminNote = body.adminNote + if (adminNote !== undefined && (typeof adminNote !== 'string' || adminNote.length > 2000)) { + throw new AdminBackendError('admin_note_invalid', 400) + } + const supabase = await getSupabaseAdminClient(minimumRole) + const { data, error } = await supabase.rpc('admin_mutate_subscription_v1', { + p_actor_email: session.email, + p_idempotency_key: idempotencyKey(request), + p_action: action, + p_user_id: userId, + p_tier: tier, + p_status: status, + p_current_period_end: optionalDateTime(body.currentPeriodEnd), + p_overage_credits: typeof overageCredits === 'number' ? overageCredits : null, + p_admin_note: typeof adminNote === 'string' ? adminNote : null, + p_memo: memo(body.memo) + }) + if (error) throw rpcFailure(error) + if (!data || typeof data !== 'object' || Array.isArray(data) || data.success !== true || + !data.subscription || typeof data.subscription !== 'object' || Array.isArray(data.subscription) || + data.subscription.user_id !== userId || !TIERS.has(String(data.subscription.tier)) || !STATUSES.has(String(data.subscription.status))) { + throw new AdminBackendError('invalid_admin_rpc_response', 502) + } + return NextResponse.json({ success: true, subscription: data.subscription }, { status: request.method === 'POST' ? 201 : 200, headers: { 'Cache-Control': 'no-store' } }) +} + +async function handlePayments(request: NextRequest): Promise { + if (request.method !== 'GET') return NextResponse.json({ error: 'method_not_allowed' }, { status: 405 }) + await requireVerifiedBackendSession('manager') + onlyQuery(request, ['userId', 'source']) + const userId = uuid(request.nextUrl.searchParams.get('userId'), 'user_id') + const source = request.nextUrl.searchParams.get('source') ?? 'db' + if (source !== 'db' && source !== 'payple') throw new AdminBackendError('payment_source_invalid', 400) + if (source === 'payple') { + return NextResponse.json({ error: 'payple_live_history_not_configured' }, { status: 501 }) + } + const supabase = await getSupabaseAdminClient() + const [subscriptionResult, auditResult, eventsResult, operationsResult] = await Promise.all([ + supabase.from('subscriptions') + .select('id, user_id, tier, status, provider, payment_provider, current_period_start, current_period_end, cancel_at, created_at, updated_at') + .eq('user_id', userId).maybeSingle(), + supabase.from('audit_log') + .select('id, admin_id, action, target_type, target_id, memo, created_at') + .eq('target_id', userId).eq('target_type', 'subscription').order('created_at', { ascending: false }).limit(50), + supabase.from('payment_provider_events') + .select('id, provider, event_type, event_created_at, disposition, received_at, processed_at') + .eq('user_id', userId).order('event_created_at', { ascending: false }).limit(50), + supabase.from('payment_provider_operations') + .select('id, provider, operation_type, requested_tier, state, error_code, expires_at, created_at, updated_at') + .eq('user_id', userId).order('created_at', { ascending: false }).limit(50) + ]) + if (subscriptionResult.error || auditResult.error || eventsResult.error || operationsResult.error) { + throw new AdminBackendError('payment_query_failed', 500) + } + return NextResponse.json({ + subscription: subscriptionResult.data ?? null, + auditLogs: auditResult.data ?? [], + providerEvents: eventsResult.data ?? [], + providerOperations: operationsResult.data ?? [], + liveProviderHistoryAvailable: false + }, { headers: { 'Cache-Control': 'no-store' } }) +} + +async function route(request: NextRequest, context: { params: Promise<{ operation: string }> }): Promise { + if (request.method !== 'GET' && !sameOrigin(request)) { + return NextResponse.json({ error: 'cross_site_request_rejected' }, { status: 403 }) + } + try { + const { operation } = await context.params + if (operation === 'admin-users') return await handleUsers(request) + if (operation === 'admin-subscriptions') return await handleSubscriptions(request) + if (operation === 'admin-payments') return await handlePayments(request) + return NextResponse.json({ error: 'admin_operation_not_allowed' }, { status: 404 }) + } catch (error) { + if (error instanceof AdminBackendError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + return NextResponse.json({ error: 'admin_operation_failed' }, { status: 500 }) + } +} + +export const GET = route +export const POST = route +export const PATCH = route +export const DELETE = route diff --git a/apps/admin/src/app/api/auth/login/route.ts b/apps/admin/src/app/api/auth/login/route.ts index 0251ef7..4874751 100644 --- a/apps/admin/src/app/api/auth/login/route.ts +++ b/apps/admin/src/app/api/auth/login/route.ts @@ -1,157 +1,149 @@ -// apps/admin/src/app/api/auth/login/route.ts -// D3RO Voice — Fortified Admin Authentication Handler - import { NextResponse } from 'next/server' -import { checkRateLimit, recordFailedAttempt, resetFailedAttempts, signSession } from '@/lib/security' +import { + checkRateLimit, + recordFailedAttempt, + resetFailedAttempts, + signSession +} from '@/lib/security' +import { adminCookieSecure } from '@/lib/admin-session' -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000' +export const runtime = 'nodejs' + +interface BackendAuthResponse { + token?: unknown + email?: unknown + role?: unknown + expiresAt?: unknown +} + +type AdminRole = 'manager' | 'admin' | 'super_admin' + +function apiBase(): string { + const value = process.env.API_SERVER_URL?.trim() + if (!value) throw new Error('admin_auth_unavailable') + const parsed = new URL(value) + if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') { + throw new Error('admin_auth_unavailable') + } + return parsed.origin +} + +function normalizeRole(value: unknown): AdminRole | null { + if (typeof value !== 'string') return null + const normalized = value.replace(/[_-]/g, '').toLowerCase() + if (normalized === 'superadmin') return 'super_admin' + if (normalized === 'admin') return 'admin' + if (normalized === 'manager') return 'manager' + return null +} + +function clientIdentifier(request: Request, email: string): string { + const forwarded = + request.headers.get('x-forwarded-for') ?? request.headers.get('cf-connecting-ip') ?? 'unknown' + return `${forwarded.split(',')[0].trim().slice(0, 64)}:${email}` +} + +function failure(key: string, status: number, retryAfterSeconds?: number): NextResponse { + return NextResponse.json( + { + success: false, + error: key, + ...(retryAfterSeconds ? { retryAfter: retryAfterSeconds } : {}) + }, + { + status, + headers: retryAfterSeconds ? { 'Retry-After': String(retryAfterSeconds) } : undefined + } + ) +} export async function POST(request: Request): Promise { + let body: unknown try { - // 1. Extract Client IP & Identifier for Rate Limiting - const forwardedFor = request.headers.get('x-forwarded-for') || request.headers.get('cf-connecting-ip') || '127.0.0.1' - const clientIp = forwardedFor.split(',')[0].trim() - - const body = await request.json() - const { usernameOrEmail, password, trap } = body - - // 2. Honeypot Bot Trap: If hidden bot field is filled, silently reject - if (trap) { - await new Promise((resolve) => setTimeout(resolve, 1000)) - return NextResponse.json({ success: false, message: 'Access denied' }, { status: 403 }) - } - - if (!usernameOrEmail || !password) { - return NextResponse.json( - { success: false, message: '아이디와 비밀번호를 입력해주세요.' }, - { status: 400 } - ) - } - - const trimmedUser = String(usernameOrEmail).trim().toLowerCase() - const trimmedPass = String(password).trim() - const rateLimitKey = `${clientIp}:${trimmedUser}` - - // 3. Check Sliding-Window Rate Limiter - const rateCheck = checkRateLimit(rateLimitKey) - if (!rateCheck.allowed) { - return NextResponse.json( - { - success: false, - message: `로그인 시도 횟수를 초과했습니다. 보안을 위해 ${rateCheck.retryAfterSeconds}초 후 다시 시도해주세요.`, - retryAfter: rateCheck.retryAfterSeconds, - }, - { - status: 429, - headers: { 'Retry-After': String(rateCheck.retryAfterSeconds) }, - } - ) - } - - let authenticated = false - let userRole = 'super_admin' - let userEmail = 'admin@d3ro.voice' - let token = '' - - // 4. Master Admin Verification (admin / Test1234!) - if ( - (trimmedUser === 'admin' || trimmedUser === 'admin@d3ro.voice' || trimmedUser === 'admin@d3ro.dev') && - trimmedPass === 'Test1234!' - ) { - authenticated = true - userRole = 'super_admin' - userEmail = 'admin@d3ro.voice' - token = `d3ro_tok_${Date.now()}` - } - - // 5. Backend C# API Verification - if (!authenticated) { - try { - const apiRes = await fetch(`${API_BASE}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: trimmedUser, password: trimmedPass }), - }) - - if (apiRes.ok) { - const data = await apiRes.json() - if (data.token) { - authenticated = true - token = data.token - userEmail = data.email || trimmedUser - userRole = (data.role || '').toLowerCase() === 'admin' ? 'admin' : 'super_admin' - } - } - } catch { - // Fallback catch - } - } - - // 6. Handle Authentication Failure (Anti-Brute Force Tracking) - if (!authenticated) { - const lockResult = recordFailedAttempt(rateLimitKey) - // Dynamic delay to prevent timing analysis - await new Promise((resolve) => setTimeout(resolve, 600)) - - if (lockResult.locked) { - return NextResponse.json( - { - success: false, - message: `5회 이상 잘못된 비밀번호가 입력되었습니다. 보안을 위해 계정이 ${lockResult.retryAfterSeconds}초 동안 잠깁니다.`, - retryAfter: lockResult.retryAfterSeconds, - }, - { - status: 429, - headers: { 'Retry-After': String(lockResult.retryAfterSeconds) }, - } - ) - } - - return NextResponse.json( - { success: false, message: '아이디 또는 비밀번호가 올바르지 않습니다.' }, - { status: 401 } - ) - } - - // 7. Successful Authentication -> Clear Rate Limiting - resetFailedAttempts(rateLimitKey) - - // 8. Generate Cryptographically Signed HMAC Session Token - const sessionData = { - id: 'admin-usr-1', - username: 'admin', - email: userEmail, - role: userRole, - token, - loginAt: new Date().toISOString(), - expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days - } - - const signedCookieValue = signSession(sessionData) - - const response = NextResponse.json({ - success: true, - user: { - id: sessionData.id, - email: sessionData.email, - role: sessionData.role, - }, - }) - - // 9. Set Hardened HttpOnly SameSite=Strict Cookie - response.cookies.set({ - name: 'd3ro_admin_session', - value: signedCookieValue, - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 30 * 24 * 60 * 60, - }) - - return response - } catch (error) { - const message = error instanceof Error ? error.message : 'Internal server error' - return NextResponse.json({ success: false, message }, { status: 500 }) + body = await request.json() + } catch { + return failure('invalid_request', 400) } + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return failure('invalid_request', 400) + } + + const candidate = body as Record + if (candidate.trap) return failure('access_denied', 403) + const email = + typeof candidate.usernameOrEmail === 'string' + ? candidate.usernameOrEmail.trim().toLowerCase() + : '' + const password = typeof candidate.password === 'string' ? candidate.password : '' + if (!email || email.length > 150 || !password || password.length > 256) { + return failure('invalid_request', 400) + } + + const rateLimitKey = clientIdentifier(request, email) + const rateCheck = checkRateLimit(rateLimitKey) + if (!rateCheck.allowed) { + return failure('rate_limited', 429, rateCheck.retryAfterSeconds) + } + + let backend: BackendAuthResponse + try { + const response = await fetch(`${apiBase()}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + cache: 'no-store', + signal: AbortSignal.timeout(7_000) + }) + if (!response.ok) throw new Error('invalid_credentials') + backend = (await response.json()) as BackendAuthResponse + } catch { + const lock = recordFailedAttempt(rateLimitKey) + if (!lock.locked) await new Promise((resolve) => setTimeout(resolve, 600)) + return lock.locked + ? failure('rate_limited', 429, lock.retryAfterSeconds) + : failure('invalid_credentials', 401) + } + + const role = normalizeRole(backend.role) + const token = typeof backend.token === 'string' ? backend.token : '' + const authenticatedEmail = + typeof backend.email === 'string' ? backend.email.trim().toLowerCase() : '' + const backendExpiry = + typeof backend.expiresAt === 'string' ? Date.parse(backend.expiresAt) : Number.NaN + if ( + !role || + token.length < 80 || + token.length > 8_192 || + !authenticatedEmail || + authenticatedEmail !== email || + !Number.isFinite(backendExpiry) || + backendExpiry <= Date.now() + ) { + return failure('invalid_auth_response', 502) + } + + resetFailedAttempts(rateLimitKey) + const expiresAt = Math.min(backendExpiry, Date.now() + 8 * 60 * 60 * 1_000) + const signedCookieValue = signSession({ + email: authenticatedEmail, + role, + token, + loginAt: new Date().toISOString(), + expiresAt + }) + + const response = NextResponse.json({ + success: true, + user: { email: authenticatedEmail, role } + }) + response.cookies.set({ + name: 'd3ro_admin_session', + value: signedCookieValue, + httpOnly: true, + secure: adminCookieSecure(), + sameSite: 'strict', + path: '/', + maxAge: Math.max(1, Math.floor((expiresAt - Date.now()) / 1_000)) + }) + return response } diff --git a/apps/admin/src/app/api/auth/logout/route.ts b/apps/admin/src/app/api/auth/logout/route.ts index cefcaa8..9bf6e13 100644 --- a/apps/admin/src/app/api/auth/logout/route.ts +++ b/apps/admin/src/app/api/auth/logout/route.ts @@ -2,6 +2,7 @@ // D3RO Voice — Admin Session Logout Handler import { NextResponse } from 'next/server' +import { adminCookieSecure } from '@/lib/admin-session' export async function POST(): Promise { const response = NextResponse.json({ success: true, message: 'Logged out successfully' }) @@ -10,7 +11,7 @@ export async function POST(): Promise { name: 'd3ro_admin_session', value: '', httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: adminCookieSecure(), sameSite: 'lax', path: '/', maxAge: 0, @@ -28,7 +29,7 @@ export async function GET(request: Request): Promise { name: 'd3ro_admin_session', value: '', httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: adminCookieSecure(), sameSite: 'lax', path: '/', maxAge: 0, diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css index 06f7db2..edd29fe 100644 --- a/apps/admin/src/app/globals.css +++ b/apps/admin/src/app/globals.css @@ -1,36 +1,21 @@ /* D3RO Voice Admin CRM — "Midnight Glass v2" Global Styles */ - -@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css'); -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap'); - -:root { - --d3-bg-base: #070b16; - --d3-bg-app: #0a0e1c; - --d3-bg-card: #111a30; - --d3-bg-card-hover: #152039; - --d3-bg-elevated: #1a2540; - --d3-bg-input: #0d1526; - --d3-bg-sidebar: #0b101f; - --d3-border: rgba(148, 180, 255, 0.08); - --d3-border-hl: rgba(148, 180, 255, 0.16); - --d3-accent: #3b82f6; - --d3-accent-light: #60a5fa; - --d3-cyan: #06b6d4; - --d3-purple: #8b5cf6; - --text-dim: #67789e; - --text-base: #93a4c8; - --text-bright: #eef2fb; -} +/* 폰트는 layout.tsx 의 Pretendard Variable 다이나믹 서브셋 링크로 로드한다 + (렌더를 막는 @import 이중 로드는 제거). + 색 변수는 @d3ro/ui SSOT가 MuiCssBaseline로 주입하는 --d3-*를 그대로 쓴다 + (관리자는 고정 다크: 값 미지정 시 아래 폴백). */ * { box-sizing: border-box; } +/* 의미적 강조는 세미볼드(600)까지 — 볼드(700+) 타이포그래피 금지 */ +strong, b { font-weight: 600; } + body { margin: 0; padding: 0; - background-color: var(--d3-bg-base); - color: var(--text-bright); + background-color: var(--d3-bg-app, #070b16); + color: var(--d3-text-primary, #eef2fb); font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index c592fde..bcbda73 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -21,10 +21,10 @@ export default function RootLayout({ D3RO Voice — Admin & Intelligence CRM - - - - + {/* Pretendard Variable 다이나믹 서브셋 — 화면에 쓰인 글리프만 분할 로드 (한글 웹폰트 표준). + mono는 시스템 스택(ui-monospace)을 쓰므로 별도 웹폰트를 로드하지 않는다. */} + +
diff --git a/apps/admin/src/app/login/page.tsx b/apps/admin/src/app/login/page.tsx index d563e0b..b6621d9 100644 --- a/apps/admin/src/app/login/page.tsx +++ b/apps/admin/src/app/login/page.tsx @@ -17,15 +17,33 @@ function LoginForm(): React.ReactElement { const searchParams = useSearchParams() const redirectPath = searchParams.get('redirect') || '/' - const [usernameOrEmail, setUsernameOrEmail] = useState('admin') - const [password, setPassword] = useState('Test1234!') + const [usernameOrEmail, setUsernameOrEmail] = useState('') + const [password, setPassword] = useState('') const [loading, setLoading] = useState(false) const [errorMsg, setErrorMsg] = useState(null) + const describeLoginError = (errorKey: unknown, retryAfter: unknown): string => { + if (errorKey === 'rate_limited') { + const seconds = typeof retryAfter === 'number' && retryAfter > 0 ? retryAfter : null + return seconds + ? `로그인 시도가 제한되었습니다. ${Math.ceil(seconds / 60)}분 후 다시 시도해주세요.` + : '로그인 시도가 제한되었습니다. 잠시 후 다시 시도해주세요.' + } + if (errorKey === 'invalid_credentials') return '이메일 또는 비밀번호가 올바르지 않습니다.' + if (errorKey === 'invalid_auth_response' || errorKey === 'admin_auth_unavailable') { + return '인증 서버 응답이 올바르지 않습니다. 관리자에게 문의해주세요.' + } + return '인증에 실패했습니다.' + } + const handleLogin = async (e?: React.FormEvent): Promise => { if (e) e.preventDefault() if (!usernameOrEmail || !password) { - setErrorMsg('아이디와 비밀번호를 입력해주세요.') + setErrorMsg('이메일과 비밀번호를 입력해주세요.') + return + } + if (!usernameOrEmail.includes('@')) { + setErrorMsg('관리자 이메일 주소로 로그인해주세요. (username 로그인은 지원이 종료되었습니다)') return } @@ -42,7 +60,7 @@ function LoginForm(): React.ReactElement { const data = await res.json() if (!res.ok || !data.success) { - throw new Error(data.message || '인증에 실패했습니다.') + throw new Error(describeLoginError(data.error, data.retryAfter)) } // Successful login @@ -117,7 +135,7 @@ function LoginForm(): React.ReactElement { sx={{ fontFamily: FONT_SANS, fontSize: '22px', - fontWeight: 800, + fontWeight: 600, letterSpacing: '-0.02em', color: C.bright, lineHeight: 1.2, @@ -165,13 +183,14 @@ function LoginForm(): React.ReactElement { {/* Login Form */} setUsernameOrEmail(e.target.value)} fullWidth size="small" required - autoComplete="username" + autoComplete="email" sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'rgba(10, 17, 31, 0.8)', diff --git a/apps/admin/src/app/unauthorized/page.tsx b/apps/admin/src/app/unauthorized/page.tsx index ec593cc..64eb25d 100644 --- a/apps/admin/src/app/unauthorized/page.tsx +++ b/apps/admin/src/app/unauthorized/page.tsx @@ -39,7 +39,7 @@ export default function UnauthorizedPage(): React.ReactElement { sx={{ fontFamily: FONT_SANS, fontSize: '22px', - fontWeight: 800, + fontWeight: 600, color: C.bright, mb: 1, }} diff --git a/apps/admin/src/components/admin-sidebar.tsx b/apps/admin/src/components/admin-sidebar.tsx index 4393382..3a9ccf9 100644 --- a/apps/admin/src/components/admin-sidebar.tsx +++ b/apps/admin/src/components/admin-sidebar.tsx @@ -14,8 +14,6 @@ interface NavGroup { key: string path: string label: string - badge?: string - badgeColor?: 'blue' | 'purple' | 'green' | 'orange' icon: React.ReactElement }> } @@ -38,8 +36,6 @@ const NAV_GROUPS: NavGroup[] = [ key: 'pipelines', path: '/pipelines', label: 'AI & Voice Pipelines', - badge: 'v0.2', - badgeColor: 'blue', icon: ( @@ -60,8 +56,6 @@ const NAV_GROUPS: NavGroup[] = [ key: 'releases', path: '/releases', label: 'Release & Downloads', - badge: 'v1.0', - badgeColor: 'green', icon: ( @@ -77,8 +71,6 @@ const NAV_GROUPS: NavGroup[] = [ key: 'users', path: '/users', label: 'User Directory', - badge: '4.5k', - badgeColor: 'purple', icon: ( @@ -99,8 +91,6 @@ const NAV_GROUPS: NavGroup[] = [ key: 'ads', path: '/ads', label: 'Ad Monetization', - badge: '$4.6k', - badgeColor: 'blue', icon: ( @@ -111,8 +101,6 @@ const NAV_GROUPS: NavGroup[] = [ key: 'support', path: '/support', label: 'Customer Support (CA)', - badge: '4 Live', - badgeColor: 'orange', icon: ( @@ -148,7 +136,9 @@ const NAV_GROUPS: NavGroup[] = [ }, ] -export function AdminSidebar(): React.ReactElement { +export function AdminSidebar({ identity }: { + identity: { email: string; role: 'manager' | 'admin' | 'super_admin' } +}): React.ReactElement { const pathname = usePathname() const router = useRouter() @@ -239,7 +229,7 @@ export function AdminSidebar(): React.ReactElement { sx={{ fontFamily: FONT_SANS, fontSize: '15px', - fontWeight: 700, + fontWeight: 500, letterSpacing: '-0.02em', color: C.bright, lineHeight: 1.2, @@ -261,7 +251,7 @@ export function AdminSidebar(): React.ReactElement { - {/* Live Node Pulse */} + {/* Verified local session indicator */} - LIVE + SESSION @@ -377,63 +367,12 @@ export function AdminSidebar(): React.ReactElement { - {item.badge && ( - - {item.badge} - - )} ) })} ))} - - {/* Live Service Matrix Mini-Widget */} - - - - Service Telemetry - - - 99.9% Up - - - - - - STT LATENCY - 142ms - - - OLLAMA VRAM - 4.6 GB - - - {/* User Footer */} @@ -471,20 +410,20 @@ export function AdminSidebar(): React.ReactElement { display: 'flex', alignItems: 'center', justifyContent: 'center', - fontWeight: 700, + fontWeight: 500, fontSize: '13px', color: '#ffffff', boxShadow: '0 0 12px rgba(59, 130, 246, 0.4)', }} > - A + {identity.email.slice(0, 1).toUpperCase()} - Admin User + {identity.email} - SUPER_ADMIN + {identity.role.toUpperCase()} @@ -516,4 +455,3 @@ export function AdminSidebar(): React.ReactElement { ) } - diff --git a/apps/admin/src/components/checksum-copy.tsx b/apps/admin/src/components/checksum-copy.tsx new file mode 100644 index 0000000..9f4af04 --- /dev/null +++ b/apps/admin/src/components/checksum-copy.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useState } from 'react' +import { Box, Tooltip } from '@mui/material' +import { Check, Copy } from 'lucide-react' +import { C, FONT_MONO } from '@/lib/console-theme' + +interface ChecksumCopyProps { + value: string +} + +export function ChecksumCopy({ value }: ChecksumCopyProps): React.ReactElement { + const [copied, setCopied] = useState(false) + + const handleCopy = (): void => { + void navigator.clipboard.writeText(value).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + return ( + + + {value} + + + + {copied ? : } + + + + ) +} diff --git a/apps/admin/src/components/dashboard-simulator.tsx b/apps/admin/src/components/dashboard-simulator.tsx deleted file mode 100644 index 42481ae..0000000 --- a/apps/admin/src/components/dashboard-simulator.tsx +++ /dev/null @@ -1,301 +0,0 @@ -'use client' - -// apps/admin/src/components/dashboard-simulator.tsx -// D3RO Voice — Interactive Realtime Audio & Intelligence Simulator Sandbox - -import React, { useState } from 'react' -import { Box, Typography, Button, TextField } from '@mui/material' -import { C, FONT_SANS, FONT_MONO } from '@/lib/console-theme' -import { DoubleBezelCard, TactileBadge, StatRing } from '@d3ro/ui/components/ds' - -export function DashboardSimulator(): React.ReactElement { - const [mode, setMode] = useState<'dictation' | 'meeting' | 'rag'>('dictation') - const [isRunning, setIsRunning] = useState(false) - const [step, setStep] = useState(0) - const [interimText, setInterimText] = useState('') - const [finalResult, setFinalResult] = useState | null>(null) - const [searchQuery, setSearchQuery] = useState('프로젝트 출시 일정 및 마일스톤') - - const steps = [ - { label: 'Audio Capture', desc: '16kHz Mono PCM Buffer' }, - { label: 'Whisper STT', desc: 'Faster-Whisper large-v3-turbo' }, - { label: 'LLM Orchestrator', desc: 'Ollama gemma4 / GPT-Realtime' }, - { label: 'Context / Export', desc: 'SQLite RAG / Multi-Doc' }, - ] - - const runSimulation = () => { - setIsRunning(true) - setStep(1) - setInterimText('') - setFinalResult(null) - - // Step 1: Audio buffer - setTimeout(() => { - setStep(2) - if (mode === 'dictation') { - setInterimText('오늘 회의에서 논의된 새로운 음성 인식 모델 성능에 대해서...') - } else if (mode === 'meeting') { - setInterimText('[화자 1]: 다음 주 스프린트 목표를 검토합시다. [화자 2]: STT 지연 시간을 140ms 이하로 줄였습니다.') - } else { - setInterimText('Query vector generated via nomic-embed-text (512-dim)...') - } - }, 600) - - // Step 2: STT + interim stream - setTimeout(() => { - setStep(3) - if (mode === 'dictation') { - setInterimText('오늘 회의에서 논의된 새로운 음성 인식 모델 성능에 대해서 요약 및 문서화를 요청드립니다.') - } - }, 1300) - - // Step 3: LLM & Final Result - setTimeout(() => { - setStep(4) - setIsRunning(false) - if (mode === 'dictation') { - setFinalResult({ - status: 'success', - engine: 'Whisper large-v3-turbo + Ollama gemma4:e4b', - latencyMs: 142, - speedup: '6.2x', - originalText: '오늘 회의에서 논의된 새로운 음성 인식 모델 성능에 대해서 요약 및 문서화를 요청드립니다.', - polishedText: '금일 회의에서 논의된 신규 음성 인식 모델의 성능에 대한 요약 및 문서 작성을 요청드립니다.', - tokensUsed: 48, - costUsd: 0.0, - }) - } else if (mode === 'meeting') { - setFinalResult({ - status: 'success', - meetingTitle: 'D3RO Voice v0.2.1 릴리스 및 파이프라인 최적화 회의', - diarization: { - speaker1: '팀장 (45% 발화율)', - speaker2: 'ML 엔지니어 (55% 발화율)', - }, - summary: 'Faster-Whisper turbo 사이드카 도입으로 지연 시간을 142ms로 6배 단축하였으며, Pyannote 화자 분리 정확도 96.4%를 달성함.', - actionItems: [ - '1. Windows 및 macOS 배포 패키지 무결성 검증 완료', - '2. SQLite 벡터 RAG 인덱스 4.8k 문서 동기화', - ], - generatedDocs: ['Executive Summary', 'Action Item Checklist', 'Mindmap Diagram'], - }) - } else { - setFinalResult({ - status: 'success', - query: searchQuery, - embeddingLatencyMs: 18.4, - vectorMatches: [ - { docId: 'DOC_4821', title: '2026 Q3 D3RO Voice 로드맵.md', similarity: 0.942, excerpt: 'Phase 15.5 화자 분리 및 실시간 회의 모드 8월 말 정식 출시...' }, - { docId: 'DOC_3102', title: 'Whisper_Turbo_사이드카_아키텍처.md', similarity: 0.887, excerpt: 'dual-condition parallel flush 패턴을 적용하여 버퍼 지연 최소화...' }, - ], - }) - } - }, 2100) - } - - return ( - - - - - - - - - - - - Live Voice & AI Intelligence Sandbox - - - SIMULATE VOICE CAPTURE • INTERIM STT • AUTO-POLISH • VECTOR RAG - - - - - {/* Mode Selector Tabs */} - - {(['dictation', 'meeting', 'rag'] as const).map((m) => ( - - ))} - - - - {/* Pipeline Progress Stages */} - - {steps.map((s, idx) => { - const stepNum = idx + 1 - const isActive = step === stepNum - const isDone = step > stepNum - return ( - - - - STAGE 0{stepNum} - - {isDone ? ( - ✓ DONE - ) : isActive ? ( - ● ACTIVE - ) : ( - READY - )} - - - {s.label} - - - {s.desc} - - - ) - })} - - - {/* Interactive Trigger Bar */} - - {mode === 'rag' ? ( - setSearchQuery(e.target.value)} - placeholder="Search vectorized knowledge documents..." - sx={{ - '& .MuiOutlinedInput-root': { - bgcolor: 'rgba(10, 17, 31, 0.7)', - color: C.bright, - borderRadius: '10px', - fontFamily: FONT_SANS, - fontSize: '13px', - '& fieldset': { borderColor: C.border }, - '&:hover fieldset': { borderColor: C.borderHl }, - '&.Mui-focused fieldset': { borderColor: C.accentLight }, - }, - }} - /> - ) : ( - - - {interimText || 'Waiting for voice audio input stream...'} - - {isRunning && ( - - {[12, 24, 18, 28, 14, 20, 32, 16].map((h, i) => ( - - ))} - - )} - - )} - - - - - {/* Output Results Box */} - {finalResult && ( - - - - PIPELINE EXECUTION TELEMETRY RESULT - - - SUCCESS (200 OK) - - - - {JSON.stringify(finalResult, null, 2)} - - - )} - - ) -} diff --git a/apps/admin/src/components/license-issuer-button.tsx b/apps/admin/src/components/license-issuer-button.tsx index a87cb3e..ffdea27 100644 --- a/apps/admin/src/components/license-issuer-button.tsx +++ b/apps/admin/src/components/license-issuer-button.tsx @@ -1,11 +1,11 @@ 'use client' // apps/admin/src/components/license-issuer-button.tsx -// D3RO Voice Admin — Ed25519 라이선스 발급 트리거 버튼 +// D3RO Voice Admin — Ed25519 라이선스 발급 트리거 버튼 (super_admin 전용) import { useState } from 'react' import { Button } from '@mui/material' -import { primaryButtonSx } from '@/lib/console-theme' +import { C, primaryButtonSx } from '@/lib/console-theme' import { LicenseIssuerDialog } from './license-issuer-dialog' export function LicenseIssuerButton(): React.ReactElement { @@ -19,8 +19,8 @@ export function LicenseIssuerButton(): React.ReactElement { onClick={() => setOpen(true)} sx={{ ...primaryButtonSx, - background: 'linear-gradient(135deg, #a855f7 0%, #3b82f6 100%)', - boxShadow: '0 0 12px rgba(168, 85, 247, 0.4)', + background: `linear-gradient(135deg, ${C.purple} 0%, ${C.accent} 100%)`, + boxShadow: '0 0 12px rgba(139, 92, 246, 0.4)' }} > ⚡ Issue Ed25519 License Key diff --git a/apps/admin/src/components/license-issuer-dialog.tsx b/apps/admin/src/components/license-issuer-dialog.tsx index 1c7f10a..4582537 100644 --- a/apps/admin/src/components/license-issuer-dialog.tsx +++ b/apps/admin/src/components/license-issuer-dialog.tsx @@ -1,7 +1,7 @@ 'use client' // apps/admin/src/components/license-issuer-dialog.tsx -// D3RO Voice Admin — Ed25519 비대칭 암호화 라이선스 발급 다이얼로그 +// Ed25519 라이선스 발급 다이얼로그 — 서명은 서버(/api/admin/license)에서만 수행한다. import { useState } from 'react' import { @@ -17,12 +17,8 @@ import { Select, MenuItem, Button, - Alert, + Alert } from '@mui/material' -import { - issueSignedLicenseKey, - DEFAULT_LICENSE_PRIVATE_KEY, -} from '@d3ro/core/utils/crypto-license' import type { LicenseTier } from '@d3ro/core/types' import { d3roPalette, d3roFontMono, d3roRadius } from '@d3ro/ui/theme' import { C, FONT_SANS, FONT_MONO, primaryButtonSx } from '@/lib/console-theme' @@ -32,6 +28,16 @@ interface LicenseIssuerDialogProps { onClose: () => void } +function describeIssueError(errorKey: unknown): string { + if (errorKey === 'license_signing_unavailable') { + return '서명 키가 구성되지 않았습니다. ADMIN_LICENSE_PRIVATE_KEY 환경변수를 설정해주세요.' + } + if (errorKey === 'admin_forbidden') return 'super_admin 권한이 필요한 작업입니다.' + if (errorKey === 'admin_session_invalid') return '세션이 만료되었습니다. 다시 로그인해주세요.' + if (errorKey === 'invalid_customer_email') return '고객 이메일 형식이 올바르지 않습니다.' + return '라이선스 발급에 실패했습니다.' +} + export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps): React.ReactElement { const [customerEmail, setCustomerEmail] = useState('') const [tier, setTier] = useState('pro_plus') @@ -39,10 +45,13 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps) const [machineId, setMachineId] = useState('') const [teamId, setTeamId] = useState('') const [generatedKey, setGeneratedKey] = useState(null) + const [usedDefaultKey, setUsedDefaultKey] = useState(false) + const [auditRecorded, setAuditRecorded] = useState(true) + const [loading, setLoading] = useState(false) const [copied, setCopied] = useState(false) const [error, setError] = useState(null) - const handleGenerate = () => { + const handleGenerate = async (): Promise => { setError(null) setCopied(false) if (!customerEmail.trim()) { @@ -50,36 +59,36 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps) return } + setLoading(true) try { - const now = Date.now() - let expiresAt: number | null = null - if (validity === '30d') { - expiresAt = now + 30 * 24 * 60 * 60 * 1000 - } else if (validity === '365d') { - expiresAt = now + 365 * 24 * 60 * 60 * 1000 + const response = await fetch('/api/admin/license', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + customerEmail: customerEmail.trim(), + tier, + validity, + machineId: machineId.trim(), + teamId: teamId.trim() + }) + }) + const data = (await response.json()) as Record + if (!response.ok || data.success !== true || typeof data.licenseKey !== 'string') { + throw new Error(describeIssueError(data.error)) } - - const payload = { - licenseId: `lic-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 6)}`, - tier, - customerEmail: customerEmail.trim(), - issuedAt: now, - expiresAt, - machineId: machineId.trim() || null, - teamId: teamId.trim() || undefined, - maxDevices: tier === 'enterprise' ? 999 : tier === 'team' ? 25 : tier === 'pro_plus' ? 5 : 3, - } - - const key = issueSignedLicenseKey(payload, DEFAULT_LICENSE_PRIVATE_KEY) - setGeneratedKey(key) + setGeneratedKey(data.licenseKey) + setUsedDefaultKey(data.usedDefaultKey === true) + setAuditRecorded(data.auditRecorded !== false) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to generate license key') + } finally { + setLoading(false) } } - const handleCopy = () => { + const handleCopy = (): void => { if (!generatedKey) return - navigator.clipboard.writeText(generatedKey) + void navigator.clipboard.writeText(generatedKey) setCopied(true) setTimeout(() => setCopied(false), 3000) } @@ -90,13 +99,13 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps) fontSize: 13, color: d3roPalette.text.primary, bgcolor: d3roPalette.bg.inset, - borderRadius: d3roRadius.button, + borderRadius: d3roRadius.button }, '& .MuiInputLabel-root': { fontFamily: FONT_MONO, fontSize: 12, - color: C.dim, - }, + color: C.dim + } } return ( @@ -107,21 +116,21 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps) fullWidth PaperProps={{ sx: { - bgcolor: '#0a0d14', - border: '1px solid rgba(255, 255, 255, 0.1)', + bgcolor: C.app, + border: `1px solid ${C.borderHl}`, borderRadius: '16px', boxShadow: '0 20px 40px rgba(0, 0, 0, 0.8)', - p: 1, - }, + p: 1 + } }} > - + Issue Cryptographic License Key - ED25519 ASYMMETRIC SIGNED OFFLINE / ENTERPRISE TOKEN + ED25519 SERVER-SIGNED OFFLINE / ENTERPRISE TOKEN @@ -152,7 +161,11 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps) Validity Period - setValidity(e.target.value as '30d' | '365d' | 'lifetime')} + label="Validity Period" + > 30 Days (Monthly) 1 Year (Annual) Lifetime (Permanent) @@ -184,20 +197,32 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps) {generatedKey && ( - - + + {usedDefaultKey && ( + + 저장소 기본 키로 서명되었습니다. 기본 키쌍은 공개되어 위조 방어력이 없으므로 운영 배포 전 + ADMIN_LICENSE_PRIVATE_KEY로 키를 로테이션하세요. + + )} + {!auditRecorded && ( + + 라이선스는 발급되었으나 감사 로그 기록에 실패했습니다. 백엔드 연결을 확인하세요. + + )} + SIGNED LICENSE KEY (Copy and paste into D3RO Voice Desktop App): {generatedKey} @@ -222,8 +247,8 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps) sx={{ fontFamily: FONT_MONO, fontSize: '12px', - borderColor: copied ? '#10b981' : '#a855f7', - color: copied ? '#10b981' : C.bright, + borderColor: copied ? C.green : C.purple400, + color: copied ? C.green400 : C.bright }} > {copied ? '✓ Copied to Clipboard!' : '📋 Copy License Key'} diff --git a/apps/admin/src/components/payment-history.tsx b/apps/admin/src/components/payment-history.tsx index bbedda4..ce57e79 100644 --- a/apps/admin/src/components/payment-history.tsx +++ b/apps/admin/src/components/payment-history.tsx @@ -4,7 +4,7 @@ // 결제 이력 패널 — DB + Payple 조회 import { useState, useEffect } from 'react' -import { Box, Button, CircularProgress } from '@mui/material' +import { Box, CircularProgress } from '@mui/material' import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' import { callAdminApi } from '@/lib/admin-api' @@ -25,22 +25,23 @@ interface AuditLogEntry { interface PaymentData { subscription: Record | null auditLogs: AuditLogEntry[] - paypleHistory?: Record - paypleError?: string + providerEvents: Array> + providerOperations: Array> + liveProviderHistoryAvailable: false } export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElement { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) - const [paypleLoading, setPaypleLoading] = useState(false) + const [error, setError] = useState(null) useEffect(() => { const load = async (): Promise => { try { const result = await callAdminApi(`admin-payments?userId=${userId}`) setData(result) - } catch { - // ignore + } catch (caught) { + setError(caught instanceof Error ? caught.message : 'Failed to load payment data') } finally { setLoading(false) } @@ -48,18 +49,6 @@ export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElem void load() }, [userId]) - const loadPayple = async (): Promise => { - setPaypleLoading(true) - try { - const result = await callAdminApi(`admin-payments?userId=${userId}&source=payple`) - setData(result) - } catch { - // ignore - } finally { - setPaypleLoading(false) - } - } - if (loading) { return ( @@ -69,55 +58,56 @@ export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElem } if (!data) { - return Failed to load payment data + return {error ?? 'Failed to load payment data'} } const sub = data.subscription return ( + {error && {error}} {/* Subscription summary */} {sub && ( PAYMENT INFO - - - - + + + + )} - {/* Payple direct query */} + {/* Provider ledger. Provider payloads and secret identifiers are intentionally excluded. */} - - PAYPLE HISTORY - - - {data.paypleHistory ? ( - - {JSON.stringify(data.paypleHistory, null, 2)} + PAYMENT PROVIDER EVENT LEDGER + {data.providerEvents.length === 0 ? ( + No provider events recorded + ) : data.providerEvents.map((event) => ( + + {String(event.provider).toUpperCase()} · {String(event.event_type)} · {String(event.disposition).toUpperCase()} · {new Date(String(event.event_created_at)).toLocaleString()} - ) : data.paypleError ? ( - {data.paypleError} - ) : ( - Click "Fetch from Payple" to query payment history - )} + ))} + + Live provider lookup is not connected. Raw Payple responses and provider identifiers are never exposed here. + + + + + + + PAYMENT OPERATIONS + {data.providerOperations.length === 0 ? ( + No payment operations recorded + ) : data.providerOperations.map((operation) => ( + + {String(operation.provider).toUpperCase()} · {String(operation.operation_type)} · {String(operation.state).toUpperCase()} · {new Date(String(operation.created_at)).toLocaleString()} + + ))} diff --git a/apps/admin/src/components/role-change-dialog.tsx b/apps/admin/src/components/role-change-dialog.tsx index 686bc0f..c482aff 100644 --- a/apps/admin/src/components/role-change-dialog.tsx +++ b/apps/admin/src/components/role-change-dialog.tsx @@ -77,8 +77,10 @@ export function RoleChangeDialog({ - New Role + New Role setTier(e.target.value as Tier)} label="Tier"> + Tier + - Status - setStatus(e.target.value as SubStatus)} label="Status"> ACTIVE CANCELED PAST DUE diff --git a/apps/admin/src/components/unavailable-admin-panel.tsx b/apps/admin/src/components/unavailable-admin-panel.tsx new file mode 100644 index 0000000..083d25a --- /dev/null +++ b/apps/admin/src/components/unavailable-admin-panel.tsx @@ -0,0 +1,37 @@ +import { Alert, Box, Typography } from '@mui/material' +import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds' +import { C, FONT_MONO, FONT_SANS, panelSx } from '@/lib/console-theme' + +interface UnavailableAdminPanelProps { + title: string + capability: string + reason: string +} + +export function UnavailableAdminPanel({ title, capability, reason }: UnavailableAdminPanelProps): React.ReactElement { + return ( + + + + + + + {title} + + NOT CONNECTED + + + {capability} + + + + + + 실제 관리 계약이 아직 연결되지 않았습니다. + {reason} + 샘플 수치나 성공 상태는 표시하지 않으며, 쓰기 제어도 비활성화했습니다. + + + + ) +} diff --git a/apps/admin/src/instrumentation.ts b/apps/admin/src/instrumentation.ts new file mode 100644 index 0000000..1c5937d --- /dev/null +++ b/apps/admin/src/instrumentation.ts @@ -0,0 +1,14 @@ +import { requireAdminSessionSecret } from '@/lib/admin-session' + +export async function register(): Promise { + if (process.env.NEXT_RUNTIME !== 'nodejs') return + + requireAdminSessionSecret() + const apiServerUrl = process.env.API_SERVER_URL?.trim() + if (!apiServerUrl) throw new Error('API_SERVER_URL is required') + + const parsed = new URL(apiServerUrl) + if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') { + throw new Error('API_SERVER_URL must use HTTPS in production') + } +} diff --git a/apps/admin/src/lib/ad-monetization.ts b/apps/admin/src/lib/ad-monetization.ts new file mode 100644 index 0000000..3fbd1a3 --- /dev/null +++ b/apps/admin/src/lib/ad-monetization.ts @@ -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 { + 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() + 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 + })) + } +} diff --git a/apps/admin/src/lib/admin-api.ts b/apps/admin/src/lib/admin-api.ts index abece06..9b42614 100644 --- a/apps/admin/src/lib/admin-api.ts +++ b/apps/admin/src/lib/admin-api.ts @@ -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 { headers?: Record @@ -13,33 +8,25 @@ export async function callAdminApi>( path: string, options: AdminApiOptions = {} ): Promise { - 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 = { + 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 } diff --git a/apps/admin/src/lib/admin-guard.ts b/apps/admin/src/lib/admin-guard.ts index 36b4ba9..07bdcdc 100644 --- a/apps/admin/src/lib/admin-guard.ts +++ b/apps/admin/src/lib/admin-guard.ts @@ -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 { 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 이상 */ diff --git a/apps/admin/src/lib/admin-session.ts b/apps/admin/src/lib/admin-session.ts new file mode 100644 index 0000000..dc51b6b --- /dev/null +++ b/apps/admin/src/lib/admin-session.ts @@ -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 + 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' +} diff --git a/apps/admin/src/lib/api-server.ts b/apps/admin/src/lib/api-server.ts index a7a7be1..5d9c72e 100644 --- a/apps/admin/src/lib/api-server.ts +++ b/apps/admin/src/lib/api-server.ts @@ -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(path: string): Promise { + 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 { - 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>('/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 { - 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>>('/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 { - 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 { - 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 { - 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('/endpoints') + if (!Array.isArray(data)) throw new Error('Model endpoints response is invalid') + return data } export async function fetchSttEndpoints(): Promise { - 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 { - 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 { - 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 { - const res = await fetch(`${API_BASE}/api/admin/stt-endpoints/${id}`, { method: 'DELETE' }) - return res.ok -} - -export async function setDefaultSttEndpoint(id: number): Promise { - 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 { - 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('/stt-endpoints') + if (!Array.isArray(data)) throw new Error('STT endpoints response is invalid') + return data } export async function fetchSttUsageReport(): Promise { - 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('/stt-usage') } export async function fetchUsageReport(): Promise { - 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 & Partial>>('/usage') + return { ...data, timeline: Array.isArray(data.timeline) ? data.timeline : [], features: Array.isArray(data.features) ? data.features : [] } } - - diff --git a/apps/admin/src/lib/audit-sanitize.ts b/apps/admin/src/lib/audit-sanitize.ts new file mode 100644 index 0000000..30b5a57 --- /dev/null +++ b/apps/admin/src/lib/audit-sanitize.ts @@ -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 = {} + for (const [key, item] of Object.entries(value as Record)) { + if (SENSITIVE_KEYS.has(key.toLowerCase())) continue + sanitized[key] = sanitizeValue(item, depth + 1) + } + return sanitized +} + +export function sanitizeAuditSnapshot(value: unknown): Record | null { + const sanitized = sanitizeValue(value, 0) + return sanitized && typeof sanitized === 'object' && !Array.isArray(sanitized) + ? sanitized as Record + : null +} diff --git a/apps/admin/src/lib/backend-admin-client.ts b/apps/admin/src/lib/backend-admin-client.ts new file mode 100644 index 0000000..c4466ee --- /dev/null +++ b/apps/admin/src/lib/backend-admin-client.ts @@ -0,0 +1,36 @@ +'use client' + +import type { CreateSttEndpointDto, ModelEndpoint, SttProviderEndpoint, SttTestResult, UpdateSttEndpointDto } from './api-server' + +async function request(path: string, init: RequestInit = {}): Promise { + 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 => request('/endpoints') +export const fetchSttEndpointsClient = (): Promise => request('/stt-endpoints') +function mutationHeaders(): Record { + 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 => 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 => request('/stt-endpoints', { method: 'POST', body: JSON.stringify(dto), headers: mutationHeaders() }) +export const updateSttEndpointClient = (id: number, dto: UpdateSttEndpointDto): Promise => 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 => { + 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' }) +} diff --git a/apps/admin/src/lib/backend-session.ts b/apps/admin/src/lib/backend-session.ts new file mode 100644 index 0000000..b9cd0e5 --- /dev/null +++ b/apps/admin/src/lib/backend-session.ts @@ -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 = { + 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 { + 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 + try { + identity = (await response.json()) as Record + } 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 { + 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) + }) +} diff --git a/apps/admin/src/lib/console-theme.ts b/apps/admin/src/lib/console-theme.ts index 0fa29e2..2e15334 100644 --- a/apps/admin/src/lib/console-theme.ts +++ b/apps/admin/src/lib/console-theme.ts @@ -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, diff --git a/apps/admin/src/lib/edge-session.ts b/apps/admin/src/lib/edge-session.ts new file mode 100644 index 0000000..f36dcff --- /dev/null +++ b/apps/admin/src/lib/edge-session.ts @@ -0,0 +1,40 @@ +import { + decodeAdminSessionPayload, + decodeBase64Url, + requireAdminSessionSecret, + type AdminSession +} from './admin-session' + +const encoder = new TextEncoder() + +export async function verifyEdgeSession(token: string): Promise { + 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 + } +} diff --git a/apps/admin/src/lib/forgejo-releases.ts b/apps/admin/src/lib/forgejo-releases.ts new file mode 100644 index 0000000..2f90f4c --- /dev/null +++ b/apps/admin/src/lib/forgejo-releases.ts @@ -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 { + const checksums = new Map() + 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, checksums: Map): 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 + 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 => !!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 { + 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 + } +} diff --git a/apps/admin/src/lib/security.ts b/apps/admin/src/lib/security.ts index 9a53c24..d83b768 100644 --- a/apps/admin/src/lib/security.ts +++ b/apps/admin/src/lib/security.ts @@ -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 { +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 { * Verifies and decodes a cryptographically signed session token. * Uses timingSafeEqual to prevent timing attacks. */ -export function verifySession>(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() +} diff --git a/apps/admin/src/lib/subscription-metrics.ts b/apps/admin/src/lib/subscription-metrics.ts new file mode 100644 index 0000000..53d8124 --- /dev/null +++ b/apps/admin/src/lib/subscription-metrics.ts @@ -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 { + const supabase = await getSupabaseAdminClient('manager') + + const rows: Array> = [] + 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>)) + 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 } +} diff --git a/apps/admin/src/lib/supabase-admin.ts b/apps/admin/src/lib/supabase-admin.ts new file mode 100644 index 0000000..4d39d11 --- /dev/null +++ b/apps/admin/src/lib/supabase-admin.ts @@ -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 { + 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 | 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 { + 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 + } + }) +} diff --git a/apps/admin/src/middleware.ts b/apps/admin/src/middleware.ts deleted file mode 100644 index 7c12d2a..0000000 --- a/apps/admin/src/middleware.ts +++ /dev/null @@ -1,70 +0,0 @@ -// apps/admin/src/middleware.ts -// D3RO Voice — Industrial Grade Admin Route & Security Guard - -import { NextResponse, type NextRequest } from 'next/server' - -const PUBLIC_PATHS = ['/login', '/auth/callback', '/api/auth/login', '/api/auth/logout', '/favicon.ico', '/robots.txt'] - -export async function middleware(request: NextRequest): Promise { - const { pathname } = request.nextUrl - - // 1. Check if path is public (e.g. login, static assets) - const isPublic = PUBLIC_PATHS.some((path) => pathname === path || pathname.startsWith(path + '/')) - const isStatic = pathname.startsWith('/_next') || pathname.startsWith('/static') || pathname.includes('.') - - // 2. Validate session cookie - const sessionCookie = request.cookies.get('d3ro_admin_session')?.value - let isAuthenticated = false - - if (sessionCookie) { - try { - const decoded = JSON.parse(Buffer.from(sessionCookie, 'base64').toString('utf-8')) - if (decoded && decoded.expiresAt && decoded.expiresAt > Date.now()) { - isAuthenticated = true - } - } catch { - isAuthenticated = false - } - } - - // 3. Unauthenticated access to protected route -> Redirect to /login - if (!isAuthenticated && !isPublic && !isStatic) { - const loginUrl = new URL('/login', request.url) - if (pathname !== '/') { - loginUrl.searchParams.set('redirect', pathname) - } - const redirectResponse = NextResponse.redirect(loginUrl) - addSecurityHeaders(redirectResponse) - return redirectResponse - } - - // 4. Authenticated user visiting /login -> Redirect to Dashboard / - if (isAuthenticated && pathname === '/login') { - const dashboardUrl = new URL('/', request.url) - const redirectResponse = NextResponse.redirect(dashboardUrl) - addSecurityHeaders(redirectResponse) - return redirectResponse - } - - // 5. Proceed with Security Headers attached - const response = NextResponse.next({ request: { headers: request.headers } }) - addSecurityHeaders(response) - return response -} - -function addSecurityHeaders(response: NextResponse): void { - // Anti-Crawling & Anti-Reconnaissance (Shodan, Google, Bing, AI scrapers) - response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet, noimageindex') - // Clickjacking Prevention - response.headers.set('X-Frame-Options', 'DENY') - // MIME Sniffing Prevention - response.headers.set('X-Content-Type-Options', 'nosniff') - // Referrer Privacy - response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin') - // Feature Policy - response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()') -} - -export const config = { - matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], -} diff --git a/apps/admin/src/proxy.ts b/apps/admin/src/proxy.ts new file mode 100644 index 0000000..bfa8084 --- /dev/null +++ b/apps/admin/src/proxy.ts @@ -0,0 +1,53 @@ +// D3RO Voice — Industrial Grade Admin Route & Security Guard + +import { NextResponse, type NextRequest } from 'next/server' +import { verifyEdgeSession } from '@/lib/edge-session' + +const PUBLIC_PATHS = [ + '/login', + '/auth/callback', + '/api/auth/login', + '/api/auth/logout', + '/favicon.ico', + '/robots.txt' +] + +export async function proxy(request: NextRequest): Promise { + const { pathname } = request.nextUrl + const isPublic = PUBLIC_PATHS.some((path) => pathname === path || pathname.startsWith(path + '/')) + const isStatic = + pathname.startsWith('/_next') || pathname.startsWith('/static') || pathname.includes('.') + + const sessionCookie = request.cookies.get('d3ro_admin_session')?.value + const isAuthenticated = sessionCookie ? (await verifyEdgeSession(sessionCookie)) !== null : false + + if (!isAuthenticated && !isPublic && !isStatic) { + const loginUrl = new URL('/login', request.url) + if (pathname !== '/') loginUrl.searchParams.set('redirect', pathname) + const redirectResponse = NextResponse.redirect(loginUrl) + addSecurityHeaders(redirectResponse) + return redirectResponse + } + + if (isAuthenticated && pathname === '/login') { + const redirectResponse = NextResponse.redirect(new URL('/', request.url)) + addSecurityHeaders(redirectResponse) + return redirectResponse + } + + const response = NextResponse.next({ request: { headers: request.headers } }) + addSecurityHeaders(response) + return response +} + +function addSecurityHeaders(response: NextResponse): void { + response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet, noimageindex') + response.headers.set('X-Frame-Options', 'DENY') + response.headers.set('X-Content-Type-Options', 'nosniff') + response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin') + response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()') +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] +} diff --git a/apps/admin/start.mjs b/apps/admin/start.mjs new file mode 100644 index 0000000..5b28c9e --- /dev/null +++ b/apps/admin/start.mjs @@ -0,0 +1,23 @@ +function fail(message) { + console.error(`D3RO admin startup refused: ${message}`) + process.exit(1) +} + +const sessionSecret = process.env.ADMIN_SESSION_SECRET?.trim() ?? '' +if (new TextEncoder().encode(sessionSecret).byteLength < 32) { + fail('ADMIN_SESSION_SECRET must contain at least 32 bytes') +} + +const apiServerUrl = process.env.API_SERVER_URL?.trim() ?? '' +let parsedApiServerUrl +try { + parsedApiServerUrl = new URL(apiServerUrl) +} catch { + fail('API_SERVER_URL must be an absolute URL') +} + +if (process.env.NODE_ENV === 'production' && parsedApiServerUrl.protocol !== 'https:') { + fail('API_SERVER_URL must use HTTPS in production') +} + +await import('./server.js') diff --git a/apps/api-server/Controllers/AdminController.cs b/apps/api-server/Controllers/AdminController.cs index d609886..26d7ab9 100644 --- a/apps/api-server/Controllers/AdminController.cs +++ b/apps/api-server/Controllers/AdminController.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Claims; +using System.Text.Json; using System.Threading.Tasks; using D3ROVoice.Api.Data; using D3ROVoice.Api.Dtos; @@ -13,16 +15,19 @@ namespace D3ROVoice.Api.Controllers; [ApiController] [Route("api/[controller]")] +[Authorize(Policy = "ManagerOrAbove")] public class AdminController : ControllerBase { private readonly AppDbContext _db; private readonly ISttProxyService _sttService; + private readonly IAdminOperationService _adminOperations; private static readonly DateTime _serverStartTime = DateTime.UtcNow; - public AdminController(AppDbContext db, ISttProxyService sttService) + public AdminController(AppDbContext db, ISttProxyService sttService, IAdminOperationService adminOperations) { _db = db; _sttService = sttService; + _adminOperations = adminOperations; } [HttpGet("stats")] @@ -75,73 +80,121 @@ public class AdminController : ControllerBase public async Task GetEndpoints() { var endpoints = await _db.ModelEndpoints + .AsNoTracking() .OrderBy(m => m.Id) + .Select(endpoint => new + { + endpoint.Id, + endpoint.ModelId, + endpoint.ModelName, + endpoint.Provider, + endpoint.EndpointUrl, + ApiKey = endpoint.ApiKey == "" ? "" : "••••••••", + endpoint.CostPer1kPromptTokens, + endpoint.CostPer1kCompletionTokens, + endpoint.IsActive, + endpoint.CreatedAt + }) .ToListAsync(); return Ok(endpoints); } [HttpPost("endpoints")] + [Authorize(Policy = "AdminOrAbove")] public async Task CreateEndpoint([FromBody] CreateModelEndpointDto dto) { - if (string.IsNullOrWhiteSpace(dto.ModelId) || string.IsNullOrWhiteSpace(dto.ModelName)) + if (string.IsNullOrWhiteSpace(dto.ModelId) || string.IsNullOrWhiteSpace(dto.ModelName) + || string.IsNullOrWhiteSpace(dto.Provider) || !IsValidEndpointUrl(dto.EndpointUrl) + || dto.CostPer1kPromptTokens < 0 || dto.CostPer1kCompletionTokens < 0) { - return BadRequest(new { message = "ModelId와 ModelName은 필수 항목입니다." }); + return BadRequest(new { message = "모델 식별자, 공급자, 유효한 HTTP(S) URL과 0 이상의 비용이 필요합니다." }); } - var existing = await _db.ModelEndpoints.FirstOrDefaultAsync(m => m.ModelId == dto.ModelId); - if (existing != null) - { - return Conflict(new { message = "이미 존재하는 ModelId입니다." }); - } - - var endpoint = new ServiceModelEndpoint - { - ModelId = dto.ModelId.Trim(), - ModelName = dto.ModelName.Trim(), - Provider = dto.Provider.Trim(), - EndpointUrl = dto.EndpointUrl.Trim(), - ApiKey = dto.ApiKey ?? "", - CostPer1kPromptTokens = dto.CostPer1kPromptTokens, - CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens, - IsActive = true, - CreatedAt = DateTime.UtcNow - }; - - _db.ModelEndpoints.Add(endpoint); - await _db.SaveChangesAsync(); - - return Ok(endpoint); + return await ExecuteAdminMutationAsync( + "model_endpoint.create", + dto, + "model_endpoint", + _ => dto.ModelId.Trim(), + dto.Memo, + () => Task.FromResult(null), + async () => + { + if (await _db.ModelEndpoints.AnyAsync(model => model.ModelId == dto.ModelId.Trim())) + throw new AdminOperationException("model_id_already_exists"); + var endpoint = new ServiceModelEndpoint + { + ModelId = dto.ModelId.Trim(), + ModelName = dto.ModelName.Trim(), + Provider = dto.Provider.Trim(), + EndpointUrl = dto.EndpointUrl.Trim(), + ApiKey = dto.ApiKey?.Trim() ?? "", + CostPer1kPromptTokens = dto.CostPer1kPromptTokens, + CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + _db.ModelEndpoints.Add(endpoint); + await _db.SaveChangesAsync(); + return ToModelEndpointDto(endpoint); + }); } [HttpPut("endpoints/{id}")] + [Authorize(Policy = "AdminOrAbove")] public async Task UpdateEndpoint(int id, [FromBody] UpdateModelEndpointDto dto) { - var endpoint = await _db.ModelEndpoints.FindAsync(id); - if (endpoint == null) return NotFound(); + if (id <= 0 || string.IsNullOrWhiteSpace(dto.ModelName) || string.IsNullOrWhiteSpace(dto.Provider) + || !IsValidEndpointUrl(dto.EndpointUrl) || dto.CostPer1kPromptTokens < 0 || dto.CostPer1kCompletionTokens < 0) + return BadRequest(new { message = "유효한 모델 엔드포인트 값이 필요합니다." }); - endpoint.ModelName = dto.ModelName.Trim(); - endpoint.Provider = dto.Provider.Trim(); - endpoint.EndpointUrl = dto.EndpointUrl.Trim(); - endpoint.ApiKey = dto.ApiKey ?? ""; - endpoint.CostPer1kPromptTokens = dto.CostPer1kPromptTokens; - endpoint.CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens; - endpoint.IsActive = dto.IsActive; - - await _db.SaveChangesAsync(); - return Ok(endpoint); + return await ExecuteAdminMutationAsync( + "model_endpoint.update", + new { id, dto }, + "model_endpoint", + _ => id.ToString(), + dto.Memo, + async () => await _db.ModelEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id) + .Select(endpoint => new { endpoint.Id, endpoint.ModelId, endpoint.ModelName, endpoint.Provider, endpoint.EndpointUrl, endpoint.IsActive }) + .SingleOrDefaultAsync(), + async () => + { + var endpoint = await _db.ModelEndpoints.FindAsync(id) + ?? throw new AdminOperationException("model_endpoint_not_found"); + endpoint.ModelName = dto.ModelName.Trim(); + endpoint.Provider = dto.Provider.Trim(); + endpoint.EndpointUrl = dto.EndpointUrl.Trim(); + if (dto.ApiKey != null) endpoint.ApiKey = dto.ApiKey.Trim(); + endpoint.CostPer1kPromptTokens = dto.CostPer1kPromptTokens; + endpoint.CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens; + endpoint.IsActive = dto.IsActive; + await _db.SaveChangesAsync(); + return ToModelEndpointDto(endpoint); + }); } [HttpDelete("endpoints/{id}")] - public async Task DeleteEndpoint(int id) + [Authorize(Policy = "AdminOrAbove")] + public async Task DeleteEndpoint(int id, [FromBody] AdminActionDto dto) { - var endpoint = await _db.ModelEndpoints.FindAsync(id); - if (endpoint == null) return NotFound(); - - _db.ModelEndpoints.Remove(endpoint); - await _db.SaveChangesAsync(); - - return Ok(new { message = "삭제되었습니다." }); + if (id <= 0) return BadRequest(); + return await ExecuteAdminMutationAsync( + "model_endpoint.delete", + new { id, dto.Memo }, + "model_endpoint", + _ => id.ToString(), + dto.Memo, + async () => await _db.ModelEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id) + .Select(endpoint => new { endpoint.Id, endpoint.ModelId, endpoint.ModelName, endpoint.Provider, endpoint.EndpointUrl, endpoint.IsActive }) + .SingleOrDefaultAsync(), + async () => + { + var endpoint = await _db.ModelEndpoints.FindAsync(id) + ?? throw new AdminOperationException("model_endpoint_not_found"); + _db.ModelEndpoints.Remove(endpoint); + await _db.SaveChangesAsync(); + return new { message = "삭제되었습니다.", id }; + }); } // ── STT / Transcription Provider Endpoints ──────────────────────────── @@ -163,45 +216,88 @@ public class AdminController : ControllerBase } [HttpPost("stt-endpoints")] + [Authorize(Policy = "AdminOrAbove")] public async Task CreateSttEndpoint([FromBody] CreateSttEndpointDto dto) { - if (string.IsNullOrWhiteSpace(dto.Name) || string.IsNullOrWhiteSpace(dto.EndpointUrl)) + if (string.IsNullOrWhiteSpace(dto.Name) || !IsValidEndpointUrl(dto.EndpointUrl) + || dto.CostPerMinute < 0 || dto.CostPerSecond < 0 || dto.FallbackPriority < 1) { - return BadRequest(new { message = "이름과 Endpoint URL은 필수입니다." }); + return BadRequest(new { message = "유효한 STT 이름, HTTP(S) URL, 비용, 우선순위가 필요합니다." }); } - var endpoint = await _sttService.CreateEndpointAsync(dto); - return Ok(endpoint); + return await ExecuteAdminMutationAsync( + "stt_endpoint.create", + dto, + "stt_endpoint", + result => ((SttProviderEndpointDto)result).Id.ToString(), + dto.Memo, + () => Task.FromResult(null), + async () => await _sttService.CreateEndpointAsync(dto)); } [HttpPut("stt-endpoints/{id}")] + [Authorize(Policy = "AdminOrAbove")] public async Task UpdateSttEndpoint(int id, [FromBody] UpdateSttEndpointDto dto) { - try - { - var endpoint = await _sttService.UpdateEndpointAsync(id, dto); - return Ok(endpoint); - } - catch (KeyNotFoundException) - { - return NotFound(new { message = $"STT Endpoint {id} not found." }); - } + if (id <= 0 || string.IsNullOrWhiteSpace(dto.Name) || !IsValidEndpointUrl(dto.EndpointUrl) + || dto.CostPerMinute < 0 || dto.CostPerSecond < 0 || dto.FallbackPriority < 1) + return BadRequest(new { message = "유효한 STT 엔드포인트 값이 필요합니다." }); + + return await ExecuteAdminMutationAsync( + "stt_endpoint.update", + new { id, dto }, + "stt_endpoint", + _ => id.ToString(), + dto.Memo, + async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id) + .Select(endpoint => new { endpoint.Id, endpoint.Name, endpoint.ProviderType, endpoint.EndpointUrl, endpoint.ModelId, endpoint.IsDefault, endpoint.IsActive, endpoint.FallbackPriority }) + .SingleOrDefaultAsync(), + async () => + { + try { return await _sttService.UpdateEndpointAsync(id, dto); } + catch (KeyNotFoundException) { throw new AdminOperationException("stt_endpoint_not_found"); } + }); } [HttpDelete("stt-endpoints/{id}")] - public async Task DeleteSttEndpoint(int id) + [Authorize(Policy = "AdminOrAbove")] + public async Task DeleteSttEndpoint(int id, [FromBody] AdminActionDto dto) { - var deleted = await _sttService.DeleteEndpointAsync(id); - if (!deleted) return NotFound(); - return Ok(new { message = "STT 엔드포인트가 성공적으로 삭제되었습니다." }); + if (id <= 0) return BadRequest(); + return await ExecuteAdminMutationAsync( + "stt_endpoint.delete", + new { id, dto.Memo }, + "stt_endpoint", + _ => id.ToString(), + dto.Memo, + async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id) + .Select(endpoint => new { endpoint.Id, endpoint.Name, endpoint.ProviderType, endpoint.EndpointUrl, endpoint.ModelId, endpoint.IsDefault, endpoint.IsActive, endpoint.FallbackPriority }) + .SingleOrDefaultAsync(), + async () => + { + if (!await _sttService.DeleteEndpointAsync(id)) throw new AdminOperationException("stt_endpoint_not_found"); + return new { message = "STT 엔드포인트가 성공적으로 삭제되었습니다.", id }; + }); } [HttpPost("stt-endpoints/{id}/set-default")] - public async Task SetDefaultSttEndpoint(int id) + [Authorize(Policy = "AdminOrAbove")] + public async Task SetDefaultSttEndpoint(int id, [FromBody] AdminActionDto dto) { - var success = await _sttService.SetDefaultEndpointAsync(id); - if (!success) return NotFound(); - return Ok(new { id, isDefault = true, success = true, message = "기본 클라우드 전사 프로바이더로 설정되었습니다." }); + if (id <= 0) return BadRequest(); + return await ExecuteAdminMutationAsync( + "stt_endpoint.set_default", + new { id, dto.Memo }, + "stt_endpoint", + _ => id.ToString(), + dto.Memo, + async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.IsDefault) + .Select(endpoint => new { endpoint.Id, endpoint.Name }).ToListAsync(), + async () => + { + if (!await _sttService.SetDefaultEndpointAsync(id)) throw new AdminOperationException("stt_endpoint_not_found"); + return new { id, isDefault = true, success = true, message = "기본 클라우드 전사 프로바이더로 설정되었습니다." }; + }); } [HttpPost("stt-endpoints/{id}/test")] @@ -212,9 +308,15 @@ public class AdminController : ControllerBase } [HttpPost("stt-endpoints/test-direct")] - public async Task TestSttDirect([FromQuery] string endpointUrl, [FromQuery] string? apiKey) + [Authorize(Policy = "AdminOrAbove")] + public async Task TestSttDirect([FromBody] DirectSttTestDto dto) { - var result = await _sttService.TestEndpointAsync(0, apiKey, endpointUrl); + if (!IsValidEndpointUrl(dto.EndpointUrl) || !Uri.TryCreate(dto.EndpointUrl, UriKind.Absolute, out var endpointUri)) + { + return BadRequest(new { message = "유효한 HTTP(S) Endpoint URL이 필요합니다." }); + } + + var result = await _sttService.TestEndpointAsync(0, dto.ApiKey, endpointUri.AbsoluteUri); return Ok(result); } @@ -272,4 +374,113 @@ public class AdminController : ControllerBase return Ok(report); } + + // 오프라인 Ed25519 라이선스 발급 감사. admin 콘솔이 서명 후 이 엔드포인트로 기록만 남긴다. + [HttpPost("license-audit")] + [Authorize(Policy = "SuperAdminOnly")] + [RequestSizeLimit(8 * 1024)] + public async Task RecordLicenseAudit([FromBody] LicenseAuditDto dto) + { + var actorEmail = User.FindFirstValue(ClaimTypes.Email)?.Trim().ToLowerInvariant() ?? string.Empty; + if (actorEmail.Length is < 3 or > 150) return Unauthorized(new { error = "invalid_actor" }); + + var licenseId = dto.LicenseId?.Trim() ?? string.Empty; + var customerEmail = dto.CustomerEmail?.Trim().ToLowerInvariant() ?? string.Empty; + var tier = dto.Tier?.Trim() ?? string.Empty; + var validity = dto.Validity?.Trim() ?? string.Empty; + if (licenseId.Length is < 3 or > 200) return BadRequest(new { error = "invalid_license_id" }); + if (customerEmail.Length is < 3 or > 150 || !customerEmail.Contains('@')) return BadRequest(new { error = "invalid_customer_email" }); + if (tier is not ("pro" or "pro_plus" or "team" or "enterprise")) return BadRequest(new { error = "invalid_tier" }); + if (validity is not ("30d" or "365d" or "lifetime")) return BadRequest(new { error = "invalid_validity" }); + + var afterJson = JsonSerializer.Serialize(new + { + licenseId, + customerEmail, + tier, + validity, + expiresAt = dto.ExpiresAt + }); + + _db.AdminAuditEntries.Add(new AdminAuditEntry + { + ActorEmail = actorEmail, + Action = "license.issue", + TargetType = "license", + TargetId = licenseId, + AfterJson = afterJson, + Memo = $"Issued {tier} license ({validity}) for {customerEmail}", + IdempotencyKey = Guid.NewGuid().ToString("D"), + CreatedAt = DateTime.UtcNow + }); + await _db.SaveChangesAsync(); + + return Ok(new { success = true }); + } + + private static object ToModelEndpointDto(ServiceModelEndpoint endpoint) => new + { + endpoint.Id, + endpoint.ModelId, + endpoint.ModelName, + endpoint.Provider, + endpoint.EndpointUrl, + ApiKey = string.IsNullOrWhiteSpace(endpoint.ApiKey) ? string.Empty : "••••••••", + endpoint.CostPer1kPromptTokens, + endpoint.CostPer1kCompletionTokens, + endpoint.IsActive, + endpoint.CreatedAt + }; + + private static bool IsValidEndpointUrl(string? value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || !string.IsNullOrEmpty(uri.UserInfo)) return false; + if (uri.Scheme == Uri.UriSchemeHttp) return uri.IsLoopback; + return uri.Scheme == Uri.UriSchemeHttps; + } + + private async Task ExecuteAdminMutationAsync( + string operation, + object request, + string targetType, + Func targetId, + string? memo, + Func> readBefore, + Func> mutate) + { + var actorEmail = User.FindFirstValue(ClaimTypes.Email); + if (string.IsNullOrWhiteSpace(actorEmail)) return Unauthorized(new { message = "관리자 이메일 claim이 필요합니다." }); + + var idempotencyKey = Request.Headers["Idempotency-Key"].ToString(); + try + { + var result = await _adminOperations.ExecuteAsync( + actorEmail, + operation, + idempotencyKey, + request, + targetType, + targetId, + memo ?? string.Empty, + readBefore, + mutate); + return Ok(result); + } + catch (AdminOperationException ex) when (ex.Message == "idempotency_key_reused_with_different_request") + { + return Conflict(new { message = ex.Message }); + } + catch (AdminOperationException ex) when (ex.Message.EndsWith("_not_found", StringComparison.Ordinal)) + { + return NotFound(new { message = ex.Message }); + } + catch (AdminOperationException ex) + { + return BadRequest(new { message = ex.Message }); + } + catch (DbUpdateException) + { + return Conflict(new { message = "admin_operation_conflict" }); + } + } } diff --git a/apps/api-server/Dtos/Dtos.cs b/apps/api-server/Dtos/Dtos.cs index ef04c8e..30d7b71 100644 --- a/apps/api-server/Dtos/Dtos.cs +++ b/apps/api-server/Dtos/Dtos.cs @@ -9,6 +9,14 @@ public record LoginDto(string Email, string Password); public record AuthResponseDto(string Token, string Email, string Role, DateTime ExpiresAt); public record UserInfoDto(int Id, string Email, string Role, DateTime CreatedAt, DateTime? LastLoginAt, bool IsActive); +// License issuance audit trail (offline Ed25519 license keys issued from the admin console) +public record LicenseAuditDto( + string LicenseId, + string CustomerEmail, + string Tier, + string Validity, + long? ExpiresAt); + // LLM DTOs public record LlmGenerateRequest(string Prompt, string? Model = null, string? SystemPrompt = null, double Temperature = 0.7, int MaxTokens = 2048); public record LlmGenerateResponse(string Text, string Model, int PromptTokens, int CompletionTokens, double TotalDurationMs, decimal Cost); @@ -23,7 +31,8 @@ public record CreateModelEndpointDto( string EndpointUrl, string ApiKey, decimal CostPer1kPromptTokens, - decimal CostPer1kCompletionTokens + decimal CostPer1kCompletionTokens, + string? Memo = null ); public record UpdateModelEndpointDto( @@ -33,7 +42,8 @@ public record UpdateModelEndpointDto( string ApiKey, decimal CostPer1kPromptTokens, decimal CostPer1kCompletionTokens, - bool IsActive + bool IsActive, + string? Memo = null ); public record ServerStatsDto( @@ -117,7 +127,8 @@ public record CreateSttEndpointDto( bool IsDefault, bool IsActive, int FallbackPriority, - string? ExtraHeadersJson + string? ExtraHeadersJson, + string? Memo = null ); public record UpdateSttEndpointDto( @@ -135,9 +146,12 @@ public record UpdateSttEndpointDto( bool IsDefault, bool IsActive, int FallbackPriority, - string? ExtraHeadersJson + string? ExtraHeadersJson, + string? Memo = null ); +public record AdminActionDto(string? Memo); + public record SttTestResultDto( bool Success, string Message, @@ -147,6 +161,8 @@ public record SttTestResultDto( string? ModelId ); +public record DirectSttTestDto(string EndpointUrl, string? ApiKey); + public record SttUsageReportDto( int TotalTranscriptions, double TotalAudioMinutes, @@ -172,4 +188,3 @@ public record SttUserUsageSummaryDto( double TotalAudioMinutes, decimal TotalCost ); - diff --git a/apps/api-server/Program.cs b/apps/api-server/Program.cs index 152761c..d9a8338 100644 --- a/apps/api-server/Program.cs +++ b/apps/api-server/Program.cs @@ -2,9 +2,11 @@ using System.Text; using D3ROVoice.Api.Data; using D3ROVoice.Api.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi; +using System.Threading.RateLimiting; var builder = WebApplication.CreateBuilder(args); var serverStartTime = DateTime.UtcNow; @@ -13,6 +15,19 @@ var serverStartTime = DateTime.UtcNow; builder.Services.AddControllers(); builder.Services.AddHttpClient(); builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + })); +}); builder.Services.AddSwaggerGen(c => { @@ -45,11 +60,24 @@ builder.Services.AddDbContext(options => builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // JWT Authentication Configuration -var secretKey = builder.Configuration["Jwt:SecretKey"] - ?? builder.Configuration["JWT_SECRET"] - ?? "D3ROVoice_Super_Secure_Secret_Key_2026_Key!"; +var secretKey = builder.Configuration["JWT_SECRET"]; +if (string.IsNullOrWhiteSpace(secretKey) || Encoding.UTF8.GetByteCount(secretKey) < 32) +{ + throw new InvalidOperationException("JWT_SECRET must contain at least 32 non-whitespace bytes."); +} +var jwtIssuer = builder.Configuration["JWT_ISSUER"]; +if (string.IsNullOrWhiteSpace(jwtIssuer)) +{ + throw new InvalidOperationException("JWT_ISSUER is required."); +} +var jwtAudience = builder.Configuration["JWT_AUDIENCE"]; +if (string.IsNullOrWhiteSpace(jwtAudience)) +{ + throw new InvalidOperationException("JWT_AUDIENCE is required."); +} var keyBytes = Encoding.UTF8.GetBytes(secretKey); builder.Services.AddAuthentication(options => @@ -59,25 +87,103 @@ builder.Services.AddAuthentication(options => }) .AddJwtBearer(options => { - options.RequireHttpsMetadata = false; + options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); options.SaveToken = true; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(keyBytes), - ValidateIssuer = false, - ValidateAudience = false, + ValidateIssuer = true, + ValidIssuer = jwtIssuer, + ValidateAudience = true, + ValidAudience = jwtAudience, + RequireExpirationTime = true, + ValidateLifetime = true, ClockSkew = TimeSpan.Zero }; }); +static string NormalizeAdminRole(string value) => + value.Replace("_", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal) + .Trim() + .ToLowerInvariant(); + +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("ManagerOrAbove", policy => + policy.RequireAuthenticatedUser().RequireAssertion(context => + context.User.Claims + .Where(claim => claim.Type == System.Security.Claims.ClaimTypes.Role || claim.Type == "role") + .Select(claim => NormalizeAdminRole(claim.Value)) + .Any(role => role is "manager" or "admin" or "superadmin"))); + options.AddPolicy("AdminOrAbove", policy => + policy.RequireAuthenticatedUser().RequireAssertion(context => + context.User.Claims + .Where(claim => claim.Type == System.Security.Claims.ClaimTypes.Role || claim.Type == "role") + .Select(claim => NormalizeAdminRole(claim.Value)) + .Any(role => role is "admin" or "superadmin"))); + options.AddPolicy("SuperAdminOnly", policy => + policy.RequireAuthenticatedUser().RequireAssertion(context => + context.User.Claims + .Where(claim => claim.Type == System.Security.Claims.ClaimTypes.Role || claim.Type == "role") + .Select(claim => NormalizeAdminRole(claim.Value)) + .Any(role => role is "superadmin"))); +}); + +var corsOriginsRaw = builder.Configuration["Cors:AllowedOrigins"] + ?? builder.Configuration["CORS_ALLOWED_ORIGINS"]; +if (string.IsNullOrWhiteSpace(corsOriginsRaw)) +{ + if (!builder.Environment.IsDevelopment()) + { + throw new InvalidOperationException("CORS_ALLOWED_ORIGINS is required outside Development."); + } + + corsOriginsRaw = "http://localhost:3000,http://localhost:3001,http://localhost:5173"; +} + +var corsOrigins = corsOriginsRaw + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(origin => origin.TrimEnd('/')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); +if (corsOrigins.Length == 0 || corsOrigins.Any(origin => + !Uri.TryCreate(origin, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) + || !string.IsNullOrEmpty(uri.UserInfo) + || !string.IsNullOrEmpty(uri.Query) + || !string.IsNullOrEmpty(uri.Fragment) + || uri.AbsolutePath != "/")) +{ + throw new InvalidOperationException("CORS_ALLOWED_ORIGINS must be a comma-separated list of HTTP(S) origins without paths or wildcards."); +} + +var allowedHosts = builder.Configuration["ALLOWED_HOSTS"]; +if (string.IsNullOrWhiteSpace(allowedHosts)) +{ + if (!builder.Environment.IsDevelopment()) + { + throw new InvalidOperationException("ALLOWED_HOSTS is required outside Development."); + } + + allowedHosts = "localhost;127.0.0.1"; +} +if (allowedHosts.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(host => host == "*" || host.Contains('/') || host.Contains('\\'))) +{ + throw new InvalidOperationException("ALLOWED_HOSTS must contain explicit semicolon-separated host names without wildcards or paths."); +} +builder.Configuration["AllowedHosts"] = allowedHosts; + builder.Services.AddCors(options => { - options.AddPolicy("AllowAll", policy => + options.AddPolicy("ConfiguredOrigins", policy => { - policy.AllowAnyOrigin() + policy.WithOrigins(corsOrigins) .AllowAnyMethod() - .AllowAnyHeader(); + .AllowAnyHeader() + .SetPreflightMaxAge(TimeSpan.FromHours(1)); }); }); @@ -88,6 +194,75 @@ using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureCreated(); + db.Database.ExecuteSqlRaw(""" + CREATE TABLE IF NOT EXISTS "AdminOperationRequests" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_AdminOperationRequests" PRIMARY KEY AUTOINCREMENT, + "ActorEmail" TEXT NOT NULL, + "IdempotencyKey" TEXT NOT NULL, + "Operation" TEXT NOT NULL, + "RequestHash" TEXT NOT NULL, + "ResponseJson" TEXT NOT NULL, + "CreatedAt" TEXT NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS "IX_AdminOperationRequests_ActorEmail_IdempotencyKey" + ON "AdminOperationRequests" ("ActorEmail", "IdempotencyKey"); + CREATE TABLE IF NOT EXISTS "AdminAuditEntries" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_AdminAuditEntries" PRIMARY KEY AUTOINCREMENT, + "ActorEmail" TEXT NOT NULL, + "Action" TEXT NOT NULL, + "TargetType" TEXT NOT NULL, + "TargetId" TEXT NOT NULL, + "BeforeJson" TEXT NULL, + "AfterJson" TEXT NULL, + "Memo" TEXT NOT NULL, + "IdempotencyKey" TEXT NOT NULL, + "CreatedAt" TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt" + ON "AdminAuditEntries" ("CreatedAt"); + """); + + // The former repository-wide SHA-256 password scheme and seeded admin + // credentials are compromised by design. Disable those rows so they can + // never authenticate; an operator provisions the administrator either via + // the one-time bootstrap token or the ADMIN_EMAIL/ADMIN_PASSWORD env pair + // below (auto-created only when no active administrator exists). + var legacyPasswordUsers = db.Users + .Where(user => !user.PasswordHash.StartsWith("AQAAAA")) + .ToList(); + if (legacyPasswordUsers.Count > 0) + { + foreach (var legacyUser in legacyPasswordUsers) + { + legacyUser.IsActive = false; + legacyUser.Role = "LegacyDisabled"; + } + db.SaveChanges(); + } + + // .env 기반 관리자 프로비저닝: 활성 관리자가 없을 때만 ADMIN_EMAIL/ADMIN_PASSWORD + // 로 SuperAdmin을 자동 생성한다(멱등 — 이미 있으면 건드리지 않는다). + var envAdminEmail = builder.Configuration["ADMIN_EMAIL"]?.Trim().ToLowerInvariant() ?? string.Empty; + var envAdminPassword = builder.Configuration["ADMIN_PASSWORD"] ?? string.Empty; + if ( + envAdminEmail.Length >= 3 + && envAdminEmail.Contains('@') + && envAdminPassword.Length >= 8 + && !db.Users.Any(u => u.IsActive) + ) + { + var envAdmin = new User + { + Email = envAdminEmail, + Role = "SuperAdmin", + CreatedAt = DateTime.UtcNow, + IsActive = true, + }; + envAdmin.PasswordHash = new PasswordHasher().HashPassword(envAdmin, envAdminPassword); + db.Users.Add(envAdmin); + db.SaveChanges(); + Console.WriteLine($"Provisioned SuperAdmin from ADMIN_EMAIL env: {envAdminEmail}"); + } // Default Model Endpoints if empty if (!db.ModelEndpoints.Any()) @@ -205,47 +380,78 @@ using (var scope = app.Services.CreateScope()) db.SaveChanges(); } - // Default Admin User seed & update password to Test1234! - var adminUser = db.Users.FirstOrDefault(u => u.Email == "admin" || u.Email == "admin@d3ro.voice"); - var passwordHash = AuthService.HashPassword("Test1234!"); - if (adminUser == null) - { - db.Users.AddRange( - new User - { - Email = "admin", - PasswordHash = passwordHash, - Role = "SuperAdmin", - CreatedAt = DateTime.UtcNow, - IsActive = true - }, - new User - { - Email = "admin@d3ro.voice", - PasswordHash = passwordHash, - Role = "SuperAdmin", - CreatedAt = DateTime.UtcNow, - IsActive = true - } - ); - db.SaveChanges(); - } - else - { - adminUser.PasswordHash = passwordHash; - adminUser.Role = "SuperAdmin"; - adminUser.IsActive = true; - db.SaveChanges(); - } + // Administrative accounts are never seeded with repository credentials. + // Provisioning is performed through the authenticated one-time bootstrap flow. } -app.UseSwagger(); -app.UseSwaggerUI(); +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} -app.UseCors("AllowAll"); +app.UseCors("ConfiguredOrigins"); +app.Use(async (context, next) => +{ + var isInvitePage = + context.Request.Path.StartsWithSegments("/accept-invite") + || context.Request.Path.Equals("/accept-invite.html"); + + if (isInvitePage) + { + context.Response.OnStarting(() => + { + // The invite token lives in the query string. Do not cache the page and + // prevent CDN HTML transforms (including analytics script injection). + context.Response.Headers["Cache-Control"] = "no-store, no-transform"; + context.Response.Headers["Content-Security-Policy"] = + "default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; " + + "form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; " + + "object-src 'none'; script-src 'self'; style-src 'self'"; + context.Response.Headers["Permissions-Policy"] = + "camera=(), microphone=(), geolocation=(), payment=(), usb=()"; + context.Response.Headers["Referrer-Policy"] = "no-referrer"; + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["X-Frame-Options"] = "DENY"; + return Task.CompletedTask; + }); + } + + if (context.Request.Path.Equals("/accept-invite")) + { + context.Response.StatusCode = StatusCodes.Status308PermanentRedirect; + context.Response.Headers.Location = $"/accept-invite/{context.Request.QueryString}"; + return; + } + + await next(); +}); app.UseDefaultFiles(); +// Historical mobile binaries remain in the checkout for forensics only. They +// are not official releases and must never be reachable through StaticFiles. +app.Use(async (context, next) => +{ + var requestPath = context.Request.Path.Value ?? string.Empty; + var fileName = Path.GetFileName(requestPath); + var legacyMarketingAsset = requestPath.Equals("/assets/index-D7M5UQvT.js", StringComparison.OrdinalIgnoreCase) + || requestPath.Equals("/assets/index-JlYFxlAJ.js", StringComparison.OrdinalIgnoreCase); + var mobileReleasePath = requestPath.StartsWith("/releases/", StringComparison.OrdinalIgnoreCase) + && (fileName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".aab", StringComparison.OrdinalIgnoreCase) + || (fileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) + && (fileName.Contains("android", StringComparison.OrdinalIgnoreCase) + || fileName.Contains("signed", StringComparison.OrdinalIgnoreCase)))); + if (mobileReleasePath || legacyMarketingAsset) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + await next(); +}); + app.UseStaticFiles(); +app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); @@ -272,7 +478,10 @@ app.MapGet("/api/health", () => Results.Ok(new app.MapControllers(); +app.MapFallbackToFile("/accept-invite", "accept-invite.html"); // Fallback to Admin BackOffice UI index.html app.MapFallbackToFile("/admin/{*path}", "admin/index.html"); app.Run(); + +public partial class Program { } diff --git a/docker-compose.nas.yml b/docker-compose.nas.yml index 8118b41..9144851 100644 --- a/docker-compose.nas.yml +++ b/docker-compose.nas.yml @@ -2,8 +2,6 @@ # D3RO Voice — Standalone NAS Docker Compose Specification # Includes: Core API + Promotional Site + Full Next.js Admin CRM # ============================================================================ -version: '3.8' - services: d3ro-api-server: image: d3ro-voice-api:latest @@ -15,7 +13,13 @@ services: - ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_URLS=http://+:5000 - DATA_DIR=/app/data - - JWT_SECRET=${JWT_SECRET:-D3ROVoice_Super_Secure_Secret_Key_2026_Key!} + - JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required} + - JWT_ISSUER=${JWT_ISSUER:?JWT_ISSUER is required} + - JWT_AUDIENCE=${JWT_AUDIENCE:?JWT_AUDIENCE is required} + - ADMIN_BOOTSTRAP_TOKEN=${ADMIN_BOOTSTRAP_TOKEN:?ADMIN_BOOTSTRAP_TOKEN is required} + - D3RO_API_TOKEN=${D3RO_API_TOKEN:?D3RO_API_TOKEN is required} + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:?CORS_ALLOWED_ORIGINS is required} + - ALLOWED_HOSTS=${ALLOWED_HOSTS:?ALLOWED_HOSTS is required} - TZ=${TZ:-Asia/Seoul} volumes: - ${DATA_PATH:-./data}:/app/data @@ -35,6 +39,11 @@ services: - NODE_ENV=production - PORT=3001 - NEXT_PUBLIC_API_URL=${PUBLIC_URL:-https://d3ro.chanpaca.net} + - API_SERVER_URL=${API_SERVER_URL:?API_SERVER_URL is required} + - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:?ADMIN_SESSION_SECRET is required} + - ADMIN_COOKIE_SECURE=${ADMIN_COOKIE_SECURE:-true} + - SUPABASE_URL=${SUPABASE_URL:?SUPABASE_URL is required} + - SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY:?SUPABASE_SERVICE_ROLE_KEY is required} depends_on: - d3ro-api-server logging: diff --git a/docker-compose.yml b/docker-compose.yml index 2ed2911..b9e0f07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: # ============================================================================ # D3RO Voice — Core Cloud API Server & Official Promotion Landing Site @@ -17,7 +15,13 @@ services: - ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_URLS=http://+:5000 - DATA_DIR=/app/data - - JWT_SECRET=${JWT_SECRET:-D3ROVoice_Super_Secure_Secret_Key_2026_Key!} + - JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required} + - JWT_ISSUER=${JWT_ISSUER:?JWT_ISSUER is required} + - JWT_AUDIENCE=${JWT_AUDIENCE:?JWT_AUDIENCE is required} + - ADMIN_BOOTSTRAP_TOKEN=${ADMIN_BOOTSTRAP_TOKEN:?ADMIN_BOOTSTRAP_TOKEN is required} + - D3RO_API_TOKEN=${D3RO_API_TOKEN:?D3RO_API_TOKEN is required} + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:?CORS_ALLOWED_ORIGINS is required} + - ALLOWED_HOSTS=${ALLOWED_HOSTS:?ALLOWED_HOSTS is required} - TZ=${TZ:-Asia/Seoul} volumes: - ${DATA_PATH:-./data}:/app/data @@ -43,6 +47,9 @@ services: - NODE_ENV=production - PORT=3001 - NEXT_PUBLIC_API_URL=${PUBLIC_URL:-https://d3ro.chanpaca.net} + - API_SERVER_URL=${API_SERVER_URL:?API_SERVER_URL is required} + - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:?ADMIN_SESSION_SECRET is required} + - ADMIN_COOKIE_SECURE=${ADMIN_COOKIE_SECURE:-true} depends_on: - d3ro-api-server logging: