From 94724a508185d381f3d3c19a31091c838f509153 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 28 Apr 2026 15:06:13 +0800 Subject: [PATCH 01/16] feat: add Google OAuth login with USER role and pending approval flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /api/auth/google and GET /api/auth/callback/google routes with CSRF state protection and account linking via googleId - Add getPublicOrigin() for dynamic redirect_uri (supports reverse proxy via X-Forwarded-Proto) - Add USER role to schema (default for new Google sign-ins), make password optional, add googleId and image fields - Role-based redirect after login: USER → /profile, ADMIN/DEVELOPER → /dashboard - Profile page shows pending approval alert for USER role - Dashboard redirects USER role back to profile - Login page shows specific error messages per OAuth error code Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 21 +++ prisma/schema.prisma | 6 +- src/app.ts | 125 +++++++++++++++++- src/frontend/components/DashboardLayout.tsx | 24 +++- src/frontend/hooks/useAuth.ts | 5 +- src/frontend/routes/login.tsx | 22 ++- src/frontend/routes/profile.tsx | 23 +++- src/frontend/routes/users.tsx | 13 +- src/lib/env.ts | 1 + 9 files changed, 219 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql diff --git a/prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql b/prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql new file mode 100644 index 0000000..b64087c --- /dev/null +++ b/prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql @@ -0,0 +1,21 @@ +-- AlterEnum: add USER back to Role +BEGIN; +CREATE TYPE "Role_new" AS ENUM ('USER', 'ADMIN', 'DEVELOPER'); +ALTER TABLE "public"."user" ALTER COLUMN "role" DROP DEFAULT; +ALTER TABLE "user" ALTER COLUMN "role" TYPE "Role_new" USING ("role"::text::"Role_new"); +ALTER TYPE "Role" RENAME TO "Role_old"; +ALTER TYPE "Role_new" RENAME TO "Role"; +DROP TYPE "public"."Role_old"; +ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'USER'; +COMMIT; + +-- AlterTable: make password nullable, change default role +ALTER TABLE "user" + ALTER COLUMN "password" DROP NOT NULL, + ALTER COLUMN "role" SET DEFAULT 'USER'; + +-- AlterTable: add googleId column +ALTER TABLE "user" ADD COLUMN "googleId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "user_googleId_key" ON "user"("googleId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5667144..475e633 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,6 +9,7 @@ datasource db { } enum Role { + USER ADMIN DEVELOPER } @@ -41,8 +42,9 @@ model User { id String @id @default(uuid()) name String email String @unique - password String - role Role @default(ADMIN) + password String? + googleId String? @unique + role Role @default(USER) active Boolean @default(true) image String? createdAt DateTime @default(now()) diff --git a/src/app.ts b/src/app.ts index be47ff6..8eb4205 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,14 @@ import { env } from './lib/env' import { createSystemLog } from './lib/logger' import { getMinioDownloadUrl, uploadBugImage } from './lib/minio' +function getPublicOrigin(request: Request): string { + if (process.env.BUN_PUBLIC_BASE_URL) return process.env.BUN_PUBLIC_BASE_URL.replace(/\/$/, '') + const url = new URL(request.url) + const proto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() + const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host + return `${proto ?? url.protocol.replace(':', '')}://${host}` +} + interface AuthResult { actingUserId: string reporterUserId: string | null // null jika via API key (tidak ada user spesifik) @@ -80,10 +88,117 @@ export function createApp() { }) // ─── Auth API ────────────────────────────────────── + // ─── Google OAuth ────────────────────────────────── + .get('/api/auth/google', ({ request }) => { + const origin = getPublicOrigin(request) + const state = crypto.randomUUID() + const params = new URLSearchParams({ + client_id: env.GOOGLE_CLIENT_ID, + redirect_uri: `${origin}/api/auth/callback/google`, + response_type: 'code', + scope: 'openid email profile', + state, + access_type: 'online', + prompt: 'select_account', + }) + const headers = new Headers() + headers.set('Location', `https://accounts.google.com/o/oauth2/v2/auth?${params}`) + headers.set('Set-Cookie', `oauth_state=${state}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`) + return new Response(null, { status: 302, headers }) + }, { + detail: { + summary: 'Google OAuth Login', + description: 'Menginisiasi alur Google OAuth. Meredirect pengguna ke halaman login Google.', + tags: ['Auth'], + }, + }) + + .get('/api/auth/callback/google', async ({ query, request }) => { + const { code, state, error } = query as Record + const origin = getPublicOrigin(request) + + if (error) { + return new Response(null, { status: 302, headers: { Location: '/login?error=google_denied' } }) + } + + const cookie = request.headers.get('cookie') ?? '' + const storedState = cookie.match(/oauth_state=([^;]+)/)?.[1] + if (!state || !storedState || state !== storedState) { + return new Response(null, { status: 302, headers: { Location: '/login?error=invalid_state' } }) + } + + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + redirect_uri: `${origin}/api/auth/callback/google`, + grant_type: 'authorization_code', + }), + }) + + if (!tokenRes.ok) { + return new Response(null, { status: 302, headers: { Location: '/login?error=token_failed' } }) + } + + const { access_token } = await tokenRes.json() as { access_token: string } + + const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${access_token}` }, + }) + + if (!userInfoRes.ok) { + return new Response(null, { status: 302, headers: { Location: '/login?error=userinfo_failed' } }) + } + + const { id: googleId, email, name, picture } = await userInfoRes.json() as { + id: string; email: string; name: string; picture?: string + } + + let user = await prisma.user.findFirst({ + where: { OR: [{ googleId }, { email }] }, + }) + + if (!user) { + user = await prisma.user.create({ + data: { name, email, googleId, image: picture, role: 'USER' }, + }) + } else if (!user.googleId) { + user = await prisma.user.update({ + where: { id: user.id }, + data: { googleId, image: picture ?? user.image }, + }) + } + + if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') { + user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } }) + } + + const token = crypto.randomUUID() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) + await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) + await createSystemLog(user.id, 'LOGIN', 'Logged in with Google') + + const redirectPath = user.role === 'USER' ? '/profile' : '/dashboard' + const headers = new Headers() + headers.append('Location', redirectPath) + headers.append('Set-Cookie', `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`) + headers.append('Set-Cookie', 'oauth_state=; Path=/; HttpOnly; Max-Age=0') + return new Response(null, { status: 302, headers }) + }, { + detail: { + summary: 'Google OAuth Callback', + description: 'Menerima callback dari Google, membuat/menautkan akun, membuat sesi, lalu meredirect ke /dashboard (ADMIN/DEVELOPER) atau /profile (USER baru).', + tags: ['Auth'], + }, + }) + .post('/api/auth/login', async ({ body, set }) => { const { email, password } = body let user = await prisma.user.findUnique({ where: { email } }) - if (!user || !(await Bun.password.verify(password, user.password))) { + if (!user || !user.password || !(await Bun.password.verify(password, user.password))) { set.status = 401 return { error: 'Email atau password salah' } } @@ -96,7 +211,7 @@ export function createApp() { await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` await createSystemLog(user.id, 'LOGIN', 'Logged in successfully') - return { user: { id: user.id, name: user.name, email: user.email, role: user.role } } + return { user: { id: user.id, name: user.name, email: user.email, role: user.role, image: user.image } } }, { body: t.Object({ email: t.String({ format: 'email', description: 'Email pengguna' }), @@ -135,7 +250,7 @@ export function createApp() { if (!token) { set.status = 401; return { user: null } } const session = await prisma.session.findUnique({ where: { token }, - include: { user: { select: { id: true, name: true, email: true, role: true } } }, + include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } }, }) if (!session || session.expiresAt < new Date()) { if (session) await prisma.session.delete({ where: { id: session.id } }) @@ -454,7 +569,7 @@ export function createApp() { name: t.String({ minLength: 1, description: 'Nama lengkap operator' }), email: t.String({ format: 'email', description: 'Alamat email (harus unik)' }), password: t.String({ minLength: 6, description: 'Password (minimal 6 karakter)' }), - role: t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role: ADMIN atau DEVELOPER' }), + role: t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role untuk akun yang dibuat manual: ADMIN atau DEVELOPER' }), }), detail: { summary: 'Create Operator', @@ -494,7 +609,7 @@ export function createApp() { body: t.Object({ name: t.Optional(t.String({ minLength: 1, description: 'Nama baru' })), email: t.Optional(t.String({ format: 'email', description: 'Email baru' })), - role: t.Optional(t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role baru' })), + role: t.Optional(t.Union([t.Literal('USER'), t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role baru' })), active: t.Optional(t.Boolean({ description: 'Status aktif operator' })), }), detail: { diff --git a/src/frontend/components/DashboardLayout.tsx b/src/frontend/components/DashboardLayout.tsx index 6891f49..dfb3293 100644 --- a/src/frontend/components/DashboardLayout.tsx +++ b/src/frontend/components/DashboardLayout.tsx @@ -1,20 +1,25 @@ import { APP_CONFIGS } from '@/frontend/config/appMenus' import { useLogout, useSession } from '@/frontend/hooks/useAuth' +import React from 'react' import { ActionIcon, + Alert, AppShell, Avatar, Box, Burger, Button, + Center, Group, Loader, + LoadingOverlay, Menu, NavLink, Select, Stack, Text, ThemeIcon, + Title, useComputedColorScheme, useMantineColorScheme } from '@mantine/core' @@ -26,6 +31,7 @@ import { TbApps, TbArrowLeft, TbChevronRight, + TbClock, TbDashboard, TbDeviceMobile, TbHistory, @@ -54,10 +60,17 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { const currentPath = matches[matches.length - 1]?.pathname // ─── Connect to auth system ────────────────────────── - const { data: sessionData } = useSession() + const { data: sessionData, isLoading: sessionLoading } = useSession() const user = sessionData?.user const logout = useLogout() + // Redirect USER role to profile (pending approval) + React.useEffect(() => { + if (!sessionLoading && user?.role === 'USER') { + navigate({ to: '/profile' }) + } + }, [user?.role, sessionLoading, navigate]) + // ─── Fetch registered apps from database ───────────── const { data: appsData } = useQuery({ queryKey: ['apps'], @@ -99,6 +112,15 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { logout.mutate() } + // Prevent dashboard flash for USER role while redirect is happening + if (sessionLoading || user?.role === 'USER') { + return ( +
+ +
+ ) + } + return ( (path: string, init?: RequestInit): Promise { @@ -41,7 +42,7 @@ export function useLogin() { }), onSuccess: (data) => { queryClient.setQueryData(['auth', 'session'], data) - navigate({ to: '/dashboard' }) + navigate({ to: data.user.role === 'USER' ? '/profile' : '/dashboard' }) }, }) } diff --git a/src/frontend/routes/login.tsx b/src/frontend/routes/login.tsx index 5432337..f534cfc 100644 --- a/src/frontend/routes/login.tsx +++ b/src/frontend/routes/login.tsx @@ -27,7 +27,7 @@ export const Route = createFileRoute('/login')({ queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()), }) if (data?.user) { - throw redirect({ to: '/dashboard' }) + throw redirect({ to: data.user.role === 'USER' ? '/profile' : '/dashboard' }) } } catch (e) { if (e instanceof Error) return @@ -59,7 +59,14 @@ function LoginPage() { {(login.isError || searchError) && ( } color="red" variant="light"> - {login.isError ? login.error.message : 'Google login failed, please try again.'} + {login.isError ? login.error.message : ( + { + google_denied: 'Login dengan Google dibatalkan.', + invalid_state: 'Sesi OAuth tidak valid, silakan coba lagi.', + token_failed: 'Gagal menukar token Google, silakan coba lagi.', + userinfo_failed: 'Gagal mengambil info akun Google, silakan coba lagi.', + }[searchError ?? ''] ?? 'Login dengan Google gagal, silakan coba lagi.' + )} )} @@ -89,6 +96,17 @@ function LoginPage() { > Sign in + + + + diff --git a/src/frontend/routes/profile.tsx b/src/frontend/routes/profile.tsx index be019ed..2a99298 100644 --- a/src/frontend/routes/profile.tsx +++ b/src/frontend/routes/profile.tsx @@ -1,4 +1,5 @@ import { + Alert, Avatar, Badge, Button, @@ -10,7 +11,7 @@ import { Title, } from '@mantine/core' import { createFileRoute, redirect } from '@tanstack/react-router' -import { TbLogout, TbUser } from 'react-icons/tb' +import { TbClock, TbLogout, TbUser } from 'react-icons/tb' import { useLogout, useSession } from '@/frontend/hooks/useAuth' export const Route = createFileRoute('/profile')({ @@ -30,6 +31,7 @@ export const Route = createFileRoute('/profile')({ }) const roleBadgeColor: Record = { + USER: 'gray', ADMIN: 'violet', DEVELOPER: 'red', } @@ -55,9 +57,26 @@ function ProfilePage() { + {user?.role === 'USER' && ( + } + title="Akun Menunggu Persetujuan" + color="yellow" + variant="light" + radius="md" + > + Akun kamu sedang menunggu persetujuan admin. Hubungi admin atau developer untuk mendapatkan akses ke fitur dashboard. + + )} + - + {user?.name?.charAt(0).toUpperCase()}
diff --git a/src/frontend/routes/users.tsx b/src/frontend/routes/users.tsx index 57ae6d6..00a7793 100644 --- a/src/frontend/routes/users.tsx +++ b/src/frontend/routes/users.tsx @@ -50,10 +50,8 @@ export const Route = createFileRoute('/users')({ const fetcher = (url: string) => fetch(url).then((res) => res.json()) const getRoleColor = (role: string) => { - const r = (role || '').toLowerCase() - if (r.includes('super')) return 'red' - if (r.includes('admin')) return 'brand-blue' - if (r.includes('developer')) return 'violet' + if (role === 'DEVELOPER') return 'violet' + if (role === 'ADMIN') return 'brand-blue' return 'gray' } @@ -97,7 +95,7 @@ function UsersPage() { name: '', email: '', password: '', - role: 'USER', + role: 'ADMIN', }) const handleCreateUser = async () => { @@ -119,7 +117,7 @@ function UsersPage() { mutateOperators() mutateStats() closeCreate() - setCreateForm({ name: '', email: '', password: '', role: 'USER' }) + setCreateForm({ name: '', email: '', password: '', role: 'ADMIN' }) } else { const err = await res.json() throw new Error(err.error || 'Failed to create user') @@ -457,11 +455,12 @@ function UsersPage() { { setUserId(v ?? 'all'); setPage(1) }} + data={[{ value: 'all', label: 'Semua operator' }, ...users.map((u) => ({ value: u.id, label: u.name }))]} + size="xs" + w={180} + clearable + /> + { setType(v); setPage(1) }} + data={LOG_TYPES.map((t) => ({ label: t === 'all' ? 'All' : t, value: t }))} + size="xs" + /> + + {isLoading ?
: ( + <> + + + + + Time + Operator + Type + Message + + + + {pageLogs.map((log: any) => ( + + {new Date(log.createdAt).toLocaleString('id-ID')} + + {log.user ? ( +
+ {log.user.name} + {log.user.email} +
+ ) : } +
+ {log.type} + {log.message} +
+ ))} + {pageLogs.length === 0 && ( +
Belum ada log aktivitas
+ )} +
+
+
+ {totalPages > 1 &&
} + + )} + + ) +} + +// ─── ELK Layout Utility ──────────────────────────────────────────────────────── + +const elk = new ELK() + +async function applyElkLayout( + nodes: Node[], + edges: Edge[], + direction: 'DOWN' | 'RIGHT' | 'UP' | 'LEFT' = 'RIGHT', +): Promise<{ nodes: Node[]; edges: Edge[] }> { + if (nodes.length === 0) return { nodes, edges } + const elkNodes = nodes.map((n) => ({ id: n.id, width: (n.measured?.width ?? n.data?.width ?? 200) as number, height: (n.measured?.height ?? n.data?.height ?? 60) as number })) + const elkEdges = edges.map((e) => ({ id: e.id, sources: [e.source], targets: [e.target] })) + const graph = await elk.layout({ + id: 'root', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': direction, + 'elk.spacing.nodeNode': '40', + 'elk.layered.spacing.nodeNodeBetweenLayers': '60', + }, + children: elkNodes, + edges: elkEdges, + }) + const positioned = new Map(graph.children?.map((n) => [n.id, { x: n.x ?? 0, y: n.y ?? 0 }]) ?? []) + return { + nodes: nodes.map((n) => ({ ...n, position: positioned.get(n.id) ?? n.position })), + edges, + } +} + +function useFlowAutoSave(key: string) { + const savePositions = useCallback((nodes: Node[]) => { + const pos: Record = {} + for (const n of nodes) pos[n.id] = n.position + localStorage.setItem(`dev:flow:${key}:positions`, JSON.stringify(pos)) + }, [key]) + const saveViewport = useCallback((vp: { x: number; y: number; zoom: number }) => { + localStorage.setItem(`dev:flow:${key}:viewport`, JSON.stringify(vp)) + }, [key]) + const loadPositions = useCallback((): Record => { + try { return JSON.parse(localStorage.getItem(`dev:flow:${key}:positions`) ?? '{}') } catch { return {} } + }, [key]) + const loadViewport = useCallback(() => { + try { return JSON.parse(localStorage.getItem(`dev:flow:${key}:viewport`) ?? 'null') } catch { return null } + }, [key]) + return { savePositions, saveViewport, loadPositions, loadViewport } +} + +// ─── Database Panel ──────────────────────────────────────────────────────────── + +interface SchemaField { name: string; type: string; isId: boolean; isUnique: boolean; isOptional: boolean; isList: boolean; isRelation: boolean; default?: string } +interface SchemaRelation { from: string; fromField: string; to: string; toField: string; onDelete?: string } +interface SchemaModel { name: string; tableName: string; fields: SchemaField[] } +interface SchemaEnum { name: string; values: string[] } +interface ParsedSchema { models: SchemaModel[]; enums: SchemaEnum[]; relations: SchemaRelation[] } + +function ModelNode({ data }: { data: any }) { + return ( + + + + {data.name} + {data.tableName !== data.name && @map({data.tableName})} + + + {data.fields?.map((f: SchemaField) => ( + + + {f.isId && PK} + {f.isUnique && !f.isId && UQ} + {f.name}{f.isOptional ? '?' : ''} + + {f.type} + + ))} + + + + ) +} + +function EnumNode({ data }: { data: any }) { + return ( + + + enum {data.name} + + + {data.values?.map((v: string) => ( + + {v} + + ))} + + + ) +} + +const DB_NODE_TYPES = { model: ModelNode, enum: EnumNode } + +function DatabaseFlowInner({ schema }: { schema: ParsedSchema }) { + const { fitView, setViewport } = useReactFlow() + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + const { savePositions, saveViewport, loadPositions, loadViewport } = useFlowAutoSave('db') + const saveTimer = useRef | undefined>(undefined) + + useEffect(() => { + const savedPos = loadPositions() + const hasSaved = Object.keys(savedPos).length > 0 + + const newNodes: Node[] = [ + ...schema.models.map((m, i) => ({ + id: m.name, type: 'model', + position: savedPos[m.name] ?? { x: i * 220, y: 0 }, + data: { name: m.name, tableName: m.tableName, fields: m.fields }, + })), + ...schema.enums.map((e, i) => ({ + id: `enum_${e.name}`, type: 'enum', + position: savedPos[`enum_${e.name}`] ?? { x: i * 160, y: 400 }, + data: { name: e.name, values: e.values }, + })), + ] + + const newEdges: Edge[] = schema.relations.map((r) => ({ + id: `${r.from}-${r.fromField}-${r.to}`, + source: r.from, target: r.to, + label: `${r.fromField} → ${r.toField}${r.onDelete ? ` [${r.onDelete}]` : ''}`, + markerEnd: { type: MarkerType.ArrowClosed }, + style: { stroke: 'var(--mantine-color-blue-5)' }, + })) + + if (hasSaved) { + setNodes(newNodes) + setEdges(newEdges) + const savedVp = loadViewport() + if (savedVp) setTimeout(() => setViewport(savedVp), 50) + else setTimeout(() => fitView({ padding: 0.2 }), 50) + } else { + applyElkLayout(newNodes, newEdges, 'RIGHT').then(({ nodes: ln, edges: le }) => { + setNodes(ln) + setEdges(le) + setTimeout(() => fitView({ padding: 0.2 }), 100) + }) + } + }, [schema]) + + const onNodeDragStop = useCallback((_: any, __: any, allNodes: Node[]) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => savePositions(allNodes), 500) + }, [savePositions]) + + const onMoveEnd = useCallback((_: any, vp: any) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => saveViewport(vp), 500) + }, [saveViewport]) + + return ( + + + + ) +} + +function DatabasePanel() { + const { data, isLoading, refetch } = useQuery({ + queryKey: ['admin', 'schema'], + queryFn: () => fetch('/api/admin/schema', { credentials: 'include' }).then((r) => r.json()), + }) + const schema: ParsedSchema | null = data?.schema ?? null + + return ( + + + Database Schema + refetch()}> + + {isLoading ?
: schema ? ( + + + + + + ) : Schema tidak tersedia} +
+ ) +} + +// ─── Project Panel ───────────────────────────────────────────────────────────── + +const PROJECT_VIEWS = [ + { group: 'Architecture', items: [ + { value: 'api-routes', label: 'API Routes' }, + { value: 'file-structure', label: 'File Structure' }, + { value: 'user-flow', label: 'User Flow' }, + { value: 'data-flow', label: 'Data Flow' }, + ]}, + { group: 'DevOps', items: [ + { value: 'env-map', label: 'Env Variables' }, + { value: 'test-coverage', label: 'Test Coverage' }, + { value: 'dependencies', label: 'Dependencies' }, + { value: 'migrations', label: 'Migrations' }, + ]}, + { group: 'Live', items: [ + { value: 'sessions', label: 'Sessions' }, + { value: 'live-requests', label: 'Live Requests' }, + ]}, +] + +function GenericFlowPanel({ queryKey, queryFn, buildGraph }: { + queryKey: string[] + queryFn: () => Promise + buildGraph: (data: any) => { nodes: Node[]; edges: Edge[] } +}) { + const { data, isLoading, refetch } = useQuery({ queryKey, queryFn, refetchInterval: queryKey.includes('sessions') ? 10_000 : undefined }) + const { fitView, setViewport } = useReactFlow() + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + const flowKey = queryKey.join('-') + const { savePositions, saveViewport, loadPositions, loadViewport } = useFlowAutoSave(flowKey) + const saveTimer = useRef | undefined>(undefined) + + useEffect(() => { + if (!data) return + const { nodes: newNodes, edges: newEdges } = buildGraph(data) + const savedPos = loadPositions() + const hasSaved = Object.keys(savedPos).length > 0 + + if (hasSaved) { + const withPos = newNodes.map((n) => ({ ...n, position: savedPos[n.id] ?? n.position })) + setNodes(withPos); setEdges(newEdges) + const savedVp = loadViewport() + if (savedVp) setTimeout(() => setViewport(savedVp), 50) + else setTimeout(() => fitView({ padding: 0.15 }), 50) + } else { + applyElkLayout(newNodes, newEdges, 'RIGHT').then(({ nodes: ln, edges: le }) => { + setNodes(ln); setEdges(le) + setTimeout(() => fitView({ padding: 0.15 }), 100) + }) + } + }, [data]) + + const onNodeDragStop = useCallback((_: any, __: any, all: Node[]) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => savePositions(all), 500) + }, [savePositions]) + const onMoveEnd = useCallback((_: any, vp: any) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => saveViewport(vp), 500) + }, [saveViewport]) + + if (isLoading) return
+ return ( + + + { refetch() }}> + + + + + + ) +} + +function SimpleNode({ data }: { data: any }) { + const methodColor: Record = { GET: 'green', POST: 'blue', PUT: 'orange', PATCH: 'yellow', DELETE: 'red', WS: 'violet', PAGE: 'gray' } + const authColor: Record = { public: 'gray', developer: 'violet', admin: 'blue', authenticated: 'teal', apiKeyOrSession: 'orange' } + return ( + + + + {data.method && {data.method}} + {data.auth && {data.auth}} + + {data.path ?? data.label} + {data.description && {data.description}} + + + ) +} + +const SIMPLE_NODE_TYPES = { simple: SimpleNode } + +function buildApiRoutesGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const routes: any[] = data?.routes ?? [] + const nodes: Node[] = routes.map((r, i) => ({ + id: r.path, type: 'simple', + position: { x: i * 30, y: i * 30 }, + data: { label: r.path, method: r.method, path: r.path, auth: r.auth, description: r.description }, + })) + const loginNode = nodes.find((n) => n.id === '/login') + const edges: Edge[] = [] + if (loginNode) { + for (const n of nodes) { + if (['/dev', '/dashboard', '/profile'].includes(n.id)) { + edges.push({ id: `login-${n.id}`, source: '/login', target: n.id, label: '', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: 'var(--mantine-color-blue-5)', strokeDasharray: '4' } }) + } + } + } + return { nodes, edges } +} + +function buildFileStructureGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const files: any[] = data?.files ?? [] + const catColor: Record = { route: 'blue', hook: 'cyan', component: 'teal', frontend: 'green', lib: 'orange', backend: 'red', prisma: 'violet', 'test-unit': 'yellow', 'test-integration': 'yellow', test: 'yellow', config: 'gray' } + const nodes: Node[] = files.map((f, i) => ({ + id: f.path, type: 'simple', + position: { x: i * 30, y: i * 30 }, + data: { label: f.path.split('/').pop(), path: f.path, description: `${f.lines} lines • ${f.exports.length} exports`, auth: f.category }, + style: { '--badge-color': catColor[f.category] ?? 'gray' } as React.CSSProperties, + })) + const edges: Edge[] = [] + for (const f of files) { + for (const imp of f.imports) { + if (files.find((x) => x.path === imp.from)) { + edges.push({ id: `${f.path}-${imp.from}`, source: f.path, target: imp.from, markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: 'var(--mantine-color-gray-5)', opacity: 0.5 } }) + } + } + } + return { nodes, edges } +} + +function buildEnvMapGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const vars: any[] = data?.variables ?? [] + const nodes: Node[] = [ + ...vars.map((v, i) => ({ + id: `env_${v.name}`, type: 'simple', + position: { x: 0, y: i * 70 }, + data: { label: v.name, path: v.name, method: v.required ? 'REQ' : 'OPT', auth: v.isSet ? 'set' : 'unset', description: v.description }, + })), + ...[...new Set(vars.flatMap((v) => v.usedBy))].map((f: string, i) => ({ + id: `file_${f}`, type: 'simple', + position: { x: 400, y: i * 60 }, + data: { label: f.split('/').pop(), path: f, description: f }, + })), + ] + const edges: Edge[] = vars.flatMap((v) => + v.usedBy.map((f: string) => ({ id: `${v.name}-${f}`, source: `env_${v.name}`, target: `file_${f}`, markerEnd: { type: MarkerType.ArrowClosed } })) + ) + return { nodes, edges } +} + +function buildTestCoverageGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const src: any[] = data?.sourceFiles ?? [] + const tests: any[] = data?.testFiles ?? [] + const covColor: Record = { covered: 'green', partial: 'yellow', uncovered: 'red' } + const nodes: Node[] = [ + ...src.map((f, i) => ({ + id: f.path, type: 'simple', + position: { x: 0, y: i * 60 }, + data: { label: f.path.split('/').pop(), path: f.path, auth: f.coverage, description: `${f.lines} lines • ${f.coverage}` }, + })), + ...tests.map((t, i) => ({ + id: t.path, type: 'simple', + position: { x: 400, y: i * 60 }, + data: { label: t.path.split('/').pop(), path: t.path, description: `${t.lines} lines • ${t.type}` }, + })), + ] + const edges: Edge[] = tests.flatMap((t) => + t.targets.map((target: string) => ({ id: `${t.path}-${target}`, source: target, target: t.path, markerEnd: { type: MarkerType.ArrowClosed } })) + ) + return { nodes, edges } +} + +function buildDependenciesGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const pkgs: any[] = data?.packages ?? [] + const catColor: Record = { server: 'orange', ui: 'blue', database: 'green', storage: 'cyan', build: 'gray', other: 'gray' } + const nodes: Node[] = [ + ...pkgs.map((p, i) => ({ + id: `pkg_${p.name}`, type: 'simple', + position: { x: 0, y: i * 55 }, + data: { label: p.name, method: p.type === 'runtime' ? 'RT' : 'DEV', auth: p.category, description: p.version }, + })), + ...[...new Set(pkgs.flatMap((p) => p.usedBy))].map((f: string, i) => ({ + id: `file_${f}`, type: 'simple', + position: { x: 350, y: i * 60 }, + data: { label: f.split('/').pop(), path: f, description: f }, + })), + ] + const edges: Edge[] = pkgs.flatMap((p) => + p.usedBy.map((f: string) => ({ id: `${p.name}-${f}`, source: `pkg_${p.name}`, target: `file_${f}`, markerEnd: { type: MarkerType.ArrowClosed } })) + ) + return { nodes, edges } +} + +function buildMigrationsGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const migrations: any[] = data?.migrations ?? [] + const nodes: Node[] = migrations.map((m, i) => ({ + id: m.folder, type: 'simple', + position: { x: i * 250, y: 100 }, + data: { label: m.name, description: `${m.changes.length} changes • ${new Date(m.createdAt).toLocaleDateString('id-ID')}` }, + })) + const edges: Edge[] = migrations.slice(1).map((m, i) => ({ + id: `mig-${i}`, source: migrations[i].folder, target: m.folder, markerEnd: { type: MarkerType.ArrowClosed }, + })) + return { nodes, edges } +} + +function buildSessionsGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const sessions: any[] = data?.sessions ?? [] + const roleColor: Record = { DEVELOPER: 'violet', ADMIN: 'blue', USER: 'gray' } + const uniqueUsers = [...new Map(sessions.map((s) => [s.userId, s])).values()] + const roles = [...new Set(sessions.map((s) => s.userRole))] + const nodes: Node[] = [ + ...uniqueUsers.map((s, i) => ({ + id: `user_${s.userId}`, type: 'simple', + position: { x: 0, y: i * 70 }, + data: { label: s.userName, method: s.isOnline ? 'ON' : 'OFF', auth: s.userRole.toLowerCase(), description: s.userEmail }, + })), + ...roles.map((r, i) => ({ + id: `role_${r}`, type: 'simple', + position: { x: 350, y: i * 100 }, + data: { label: r, description: `Access: ${r === 'DEVELOPER' ? '/dev, /dashboard' : r === 'ADMIN' ? '/dashboard' : '/profile'}` }, + })), + ] + const edges: Edge[] = uniqueUsers.map((s) => ({ + id: `${s.userId}-${s.userRole}`, source: `user_${s.userId}`, target: `role_${s.userRole}`, markerEnd: { type: MarkerType.ArrowClosed }, + })) + return { nodes, edges } +} + +// Static flow graphs +function buildUserFlowGraph(): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = [ + { id: 'visit', type: 'simple', position: { x: 0, y: 0 }, data: { label: 'Visit App', description: 'User opens browser' } }, + { id: 'login', type: 'simple', position: { x: 200, y: 0 }, data: { label: '/login', description: 'Authentication page' } }, + { id: 'auth-check', type: 'simple', position: { x: 400, y: 0 }, data: { label: 'Auth Check', description: 'Validate session' } }, + { id: 'dev', type: 'simple', position: { x: 600, y: -100 }, data: { label: '/dev', auth: 'developer', description: 'Dev Console (DEVELOPER)' } }, + { id: 'dashboard', type: 'simple', position: { x: 600, y: 0 }, data: { label: '/dashboard', auth: 'admin', description: 'Admin Dashboard (ADMIN+)' } }, + { id: 'profile', type: 'simple', position: { x: 600, y: 100 }, data: { label: '/profile', auth: 'authenticated', description: 'User Profile (all)' } }, + ] + const edges: Edge[] = [ + { id: 'v-l', source: 'visit', target: 'login', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'l-a', source: 'login', target: 'auth-check', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'a-d', source: 'auth-check', target: 'dev', label: 'DEVELOPER', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'a-da', source: 'auth-check', target: 'dashboard', label: 'ADMIN', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'a-p', source: 'auth-check', target: 'profile', label: 'USER', markerEnd: { type: MarkerType.ArrowClosed } }, + ] + return { nodes, edges } +} + +function buildDataFlowGraph(): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = [ + { id: 'client', type: 'simple', position: { x: 0, y: 0 }, data: { label: 'Client (React)', description: 'TanStack Router + Query' } }, + { id: 'elysia', type: 'simple', position: { x: 200, y: 0 }, data: { label: 'Elysia.js', description: 'HTTP framework (Bun)' } }, + { id: 'auth-mw', type: 'simple', position: { x: 400, y: -80 }, data: { label: 'Auth Middleware', description: 'Session cookie / API Key' } }, + { id: 'handler', type: 'simple', position: { x: 400, y: 80 }, data: { label: 'Route Handler', description: 'Business logic' } }, + { id: 'prisma', type: 'simple', position: { x: 600, y: 0 }, data: { label: 'Prisma ORM', description: 'PostgreSQL queries' } }, + { id: 'minio', type: 'simple', position: { x: 600, y: 120 }, data: { label: 'MinIO', description: 'Object storage (images)' } }, + { id: 'redis', type: 'simple', position: { x: 600, y: -120 }, data: { label: 'Redis', description: 'App log ring buffer' } }, + { id: 'response', type: 'simple', position: { x: 800, y: 0 }, data: { label: 'JSON Response', description: 'Back to client' } }, + ] + const edges: Edge[] = [ + { id: 'c-e', source: 'client', target: 'elysia', label: 'HTTP / WS', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'e-a', source: 'elysia', target: 'auth-mw', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'e-h', source: 'elysia', target: 'handler', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'h-p', source: 'handler', target: 'prisma', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'h-m', source: 'handler', target: 'minio', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'h-r', source: 'handler', target: 'redis', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'p-res', source: 'prisma', target: 'response', markerEnd: { type: MarkerType.ArrowClosed } }, + ] + return { nodes, edges } +} + +// Live Requests sub-view +function LiveRequestsPanel() { + const [requests, setRequests] = useState([]) + const [paused, setPaused] = useState(false) + const wsRef = useRef(null) + + useEffect(() => { + const proto = location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${proto}://${location.host}/ws/presence`) + wsRef.current = ws + ws.onmessage = (e) => { + const msg = JSON.parse(e.data) + if (msg.type === 'request' && !paused) { + setRequests((prev) => [msg, ...prev].slice(0, 100)) + } + } + return () => { ws.close() } + }, [paused]) + + const statusColor = (s: number) => s >= 500 ? 'red' : s >= 400 ? 'orange' : s >= 300 ? 'yellow' : 'green' + + return ( + + + Live Requests + + + + + + + + + + Time + Method + Path + Status + Duration + + + + {requests.map((r, i) => ( + + {new Date(r.timestamp).toLocaleTimeString('id-ID')} + )[r.method] ?? 'gray'} variant="filled">{r.method} + {r.path} + {r.status} + {r.duration}ms + + ))} + {requests.length === 0 && ( +
Menunggu request masuk...
+ )} +
+
+
+
+ ) +} + +function ProjectPanel() { + const [view, setView] = useState('api-routes') + + const viewData = useMemo(() => { + switch (view) { + case 'api-routes': return { + queryKey: ['admin', 'routes'], + queryFn: () => fetch('/api/admin/routes', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildApiRoutesGraph, + } + case 'file-structure': return { + queryKey: ['admin', 'project-structure'], + queryFn: () => fetch('/api/admin/project-structure', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildFileStructureGraph, + } + case 'env-map': return { + queryKey: ['admin', 'env-map'], + queryFn: () => fetch('/api/admin/env-map', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildEnvMapGraph, + } + case 'test-coverage': return { + queryKey: ['admin', 'test-coverage'], + queryFn: () => fetch('/api/admin/test-coverage', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildTestCoverageGraph, + } + case 'dependencies': return { + queryKey: ['admin', 'dependencies'], + queryFn: () => fetch('/api/admin/dependencies', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildDependenciesGraph, + } + case 'migrations': return { + queryKey: ['admin', 'migrations'], + queryFn: () => fetch('/api/admin/migrations', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildMigrationsGraph, + } + case 'sessions': return { + queryKey: ['admin', 'sessions'], + queryFn: () => fetch('/api/admin/sessions', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildSessionsGraph, + } + default: return null + } + }, [view]) + + const isStaticView = view === 'user-flow' || view === 'data-flow' + const isLiveView = view === 'live-requests' + + const staticGraph = useMemo(() => { + if (view === 'user-flow') return buildUserFlowGraph() + if (view === 'data-flow') return buildDataFlowGraph() + return null + }, [view]) + + return ( + + + Project + { - setLogType(val) - setPage(1) - }} - /> - { setOperatorId(v ?? 'all'); setPage(1) }} + data={operatorOptions} + w={180} + clearable + /> + { setDateRange(v); setPage(1) }} + locale="id" + valueFormat="DD MMM YYYY" + clearable + w={300} + /> + { setType(v); setPage(1) }} + data={LOG_TYPES.map((t) => ({ label: t === 'all' ? 'All' : t, value: t }))} + /> + - {/* Timeline Content */} - {isLoading ? ( -
- Loading logs... -
- ) : filteredTimeline.length === 0 ? ( - No logs found matching your filters. +
) : ( <> - {filteredTimeline.map((group, groupIndex) => ( - - 0 ? "xl" : 0} - mb="md" - style={{ textTransform: 'uppercase' }} - > - {group.date} - - - - {group.logs.map((log, logIndex) => { - const isLastLog = logIndex === group.logs.length - 1; - - return ( - - {/* Left: Time */} - - {log.time} - - - {/* Middle: Line & Avatar */} - - {/* Vertical Line */} - {!isLastLog && ( - - )} - {/* Avatar */} - - - - {log.icon ? : (log.user?.name?.charAt(0) || '?')} - - - - - - {/* Right: Content */} - - - {log.user?.name || 'Unknown'} - {log.content} - - - - ) - })} - - - {groupIndex < filteredTimeline.length - 1 && ( - - )} - - ))} - - {response?.totalPages > 1 && ( -
- + + + + + + + + + + + Time + Operator + Type + Message + + + + {logs.map((log: any) => ( + + + {new Date(log.createdAt).toLocaleString('id-ID')} + + + {log.user ? ( +
+ {log.user.name} + {log.user.email} +
+ ) : } +
+ + + {log.type} + + + + {log.message} + +
+ ))} + {logs.length === 0 && ( + + +
Belum ada log aktivitas
+
+
+ )} +
+
+
+ {totalPages > 1 && ( +
+
)} )} - + ) From 7c5a491ba9f908a767295d613cc46cf16226c250 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 29 Apr 2026 11:33:39 +0800 Subject: [PATCH 04/16] upd: update role management descriptions and add USER role card Co-Authored-By: Claude Sonnet 4.6 --- src/frontend/routes/users.tsx | 36 +++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/frontend/routes/users.tsx b/src/frontend/routes/users.tsx index 00a7793..0de38a5 100644 --- a/src/frontend/routes/users.tsx +++ b/src/frontend/routes/users.tsx @@ -58,13 +58,37 @@ const getRoleColor = (role: string) => { const roles = [ { name: 'DEVELOPER', - color: 'red', - permissions: ['Full Access', 'Error Feedback', 'Error Management', 'App Version Management', 'User Management'] + color: 'violet', + description: 'Super admin dengan akses penuh ke seluruh sistem termasuk Dev Console.', + permissions: [ + 'Akses Dev Console (/dev)', + 'Manajemen user & role', + 'Kelola bug report & feedback', + 'Lihat semua app & log aktivitas', + 'Kelola versi & status aplikasi', + 'Hapus log sistem', + ], }, { name: 'ADMIN', - color: 'orange', - permissions: ['View All Apps', 'View Logs', 'Report Errors'] + color: 'blue', + description: 'Operator yang dapat mengelola aplikasi, bug, dan melihat log aktivitas.', + permissions: [ + 'Lihat & kelola semua aplikasi', + 'Kelola bug report', + 'Lihat log aktivitas', + 'Lihat data user, desa, orders', + 'Update status village & produk', + ], + }, + { + name: 'USER', + color: 'gray', + description: 'Akun baru yang belum disetujui. Menunggu approval dari Admin atau Developer.', + permissions: [ + 'Akses halaman profil', + 'Lihat status persetujuan akun', + ], }, ] @@ -343,8 +367,8 @@ function UsersPage() { - {role.name.replace('_', ' ')} - Core role for secure app management. + {role.name} + {role.description} From 73aa9729b8fafe2aefa6b169b5f81aa5c3997259 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 29 Apr 2026 11:39:13 +0800 Subject: [PATCH 05/16] upd: remove Settings menu item from DashboardLayout user menu Co-Authored-By: Claude Sonnet 4.6 --- src/frontend/components/DashboardLayout.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/frontend/components/DashboardLayout.tsx b/src/frontend/components/DashboardLayout.tsx index dfb3293..9470c48 100644 --- a/src/frontend/components/DashboardLayout.tsx +++ b/src/frontend/components/DashboardLayout.tsx @@ -201,12 +201,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { > Profile - } - onClick={() => navigate({ to: '/dashboard' })} - > - Settings - Danger Zone Date: Wed, 29 Apr 2026 11:58:31 +0800 Subject: [PATCH 06/16] feat: block inactive users from login and fix activity log on dev operators - Block inactive users on email/password login (403) - Block inactive users on Google OAuth (redirect to account_disabled) - Auto-logout inactive users on session check (deleteMany sessions) - Delete sessions when user is deactivated via PATCH /api/operators/:id - Add account_disabled error message on login page - Show inactive indicator on users table with reactivate button - Add createSystemLog calls to /api/admin/users role and activate endpoints Co-Authored-By: Claude Sonnet 4.6 --- src/app.ts | 21 ++++++++- src/frontend/routes/login.tsx | 1 + src/frontend/routes/users.tsx | 86 ++++++++++++++++++++++++++++------- 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/src/app.ts b/src/app.ts index 64982eb..5c70a0c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -203,6 +203,10 @@ export function createApp() { }) } + if (!user.active) { + return new Response(null, { status: 302, headers: { Location: '/login?error=account_disabled' } }) + } + if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') { user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } }) } @@ -233,6 +237,10 @@ export function createApp() { set.status = 401 return { error: 'Email atau password salah' } } + if (!user.active) { + set.status = 403 + return { error: 'Akun Anda telah dinonaktifkan. Hubungi admin untuk informasi lebih lanjut.' } + } // Auto-promote super admin from env if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') { user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } }) @@ -281,13 +289,18 @@ export function createApp() { if (!token) { set.status = 401; return { user: null } } const session = await prisma.session.findUnique({ where: { token }, - include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } }, + include: { user: { select: { id: true, name: true, email: true, role: true, image: true, active: true } } }, }) if (!session || session.expiresAt < new Date()) { if (session) await prisma.session.delete({ where: { id: session.id } }) set.status = 401 return { user: null } } + if (!session.user.active) { + await prisma.session.deleteMany({ where: { userId: session.user.id } }) + set.status = 401 + return { user: null } + } return { user: session.user } }, { detail: { @@ -641,6 +654,10 @@ export function createApp() { }, }) + if (body.active === false) { + await prisma.session.deleteMany({ where: { userId: id } }) + } + if (userId) { await createSystemLog(userId, 'UPDATE', `Updated user: ${user.name} (${user.email})`) } @@ -1054,6 +1071,7 @@ export function createApp() { select: { id: true, name: true, email: true, role: true, active: true, createdAt: true }, }) await appLog('info', `Role changed: ${user.email} ${target?.role} → ${role}`) + await createSystemLog(auth.userId, 'UPDATE', `Role changed: ${user.name} (${user.email}) ${target?.role} → ${role}`) return { user } }) @@ -1069,6 +1087,7 @@ export function createApp() { }) if (!active) await prisma.session.deleteMany({ where: { userId: params.id } }) await appLog('info', `User ${active ? 'activated' : 'deactivated'}: ${user.email}`) + await createSystemLog(auth.userId, active ? 'UPDATE' : 'DELETE', `User ${active ? 'activated' : 'deactivated'}: ${user.name} (${user.email})`) return { user } }) diff --git a/src/frontend/routes/login.tsx b/src/frontend/routes/login.tsx index 7432d4f..ff41ee9 100644 --- a/src/frontend/routes/login.tsx +++ b/src/frontend/routes/login.tsx @@ -66,6 +66,7 @@ function LoginPage() { invalid_state: 'Sesi OAuth tidak valid, silakan coba lagi.', token_failed: 'Gagal menukar token Google, silakan coba lagi.', userinfo_failed: 'Gagal mengambil info akun Google, silakan coba lagi.', + account_disabled: 'Akun Anda telah dinonaktifkan. Hubungi admin untuk informasi lebih lanjut.', }[searchError ?? ''] ?? 'Login dengan Google gagal, silakan coba lagi.' )} diff --git a/src/frontend/routes/users.tsx b/src/frontend/routes/users.tsx index 0de38a5..a87e060 100644 --- a/src/frontend/routes/users.tsx +++ b/src/frontend/routes/users.tsx @@ -4,6 +4,7 @@ import { ActionIcon, Avatar, Badge, + Box, Button, Card, Container, @@ -23,6 +24,7 @@ import { TextInput, ThemeIcon, Title, + Tooltip, } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' import { notifications } from '@mantine/notifications' @@ -37,7 +39,8 @@ import { TbSearch, TbShieldCheck, TbTrash, - TbUserCheck + TbUserCheck, + TbUserPlus, } from 'react-icons/tb' import useSWR from 'swr' import { API_URLS } from '../config/api' @@ -229,6 +232,28 @@ function UsersPage() { } } + // ── Activate User ── + const handleActivateUser = async (user: any) => { + try { + const res = await fetch(`/api/operators/${user.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ active: true }), + }) + if (res.ok) { + notifications.show({ title: 'Success', message: `${user.name} telah diaktifkan kembali.`, color: 'teal', icon: }) + mutateOperators() + mutateStats() + } else { + const err = await res.json() + throw new Error(err.error || 'Failed to activate user') + } + } catch (e: any) { + notifications.show({ title: 'Error', message: e.message || 'Something went wrong.', color: 'red', icon: }) + } + } + return ( @@ -306,33 +331,62 @@ function UsersPage() { ) : ( operators.map((user: any) => ( - + - - {user.name.charAt(0)} - + + + {user.name.charAt(0)} + + {user.active === false && ( + + )} + - {user.name} + + {user.name} + {user.active === false && ( + Inactive + )} + {user.email} - - + + {user.role} - - {new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} + + + {new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} + - handleOpenEdit(user)}> - - - handleOpenDelete(user)}> - - + {user.active === false ? ( + + handleActivateUser(user)}> + + + + ) : ( + <> + handleOpenEdit(user)}> + + + handleOpenDelete(user)}> + + + + )} From dbbe53584c9cb224644213eebe54bf54dbd7770f Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 29 Apr 2026 12:01:44 +0800 Subject: [PATCH 07/16] upd: sync compose.yml and .env.example with all env vars in env.ts Add missing required vars: API_KEY, MINIO_*, and optional REDIS_URL, BUN_PUBLIC_BASE_URL Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 17 +++++++++++++---- compose.yml | 21 +++++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 6721966..fed4a5b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # App PORT=3000 NODE_ENV=development +BUN_PUBLIC_BASE_URL=http://localhost:3000 # Dev Inspector REACT_EDITOR=code @@ -13,12 +14,20 @@ DIRECT_URL=postgresql://user:password@localhost:5432/base-template GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -# Role +# Super Admin (comma-separated emails) SUPER_ADMIN_EMAIL=admin@example.com # API Key for external clients (e.g. mobile apps) API_KEY=your-secret-api-key-here -# Telegram Notification (optional) -TELEGRAM_NOTIFY_TOKEN= -TELEGRAM_NOTIFY_CHAT_ID= +# MinIO (object storage for bug report images) +MINIO_ENDPOINT= +MINIO_PORT=443 +MINIO_USE_SSL=true +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +MINIO_BUCKET= +MINIO_UPLOAD_DIR=bug-reports + +# Redis (optional — enables App Logs feature on /dev) +REDIS_URL= diff --git a/compose.yml b/compose.yml index aaaca7d..43b44b7 100644 --- a/compose.yml +++ b/compose.yml @@ -4,17 +4,30 @@ services: container_name: monitoring-app-stg restart: unless-stopped environment: + # App + - PORT=${PORT:-3000} + - NODE_ENV=${NODE_ENV:-production} + - BUN_PUBLIC_BASE_URL=${BUN_PUBLIC_BASE_URL} # Database - DATABASE_URL=${DATABASE_URL} - DIRECT_URL=${DIRECT_URL} # Google OAuth - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - # App - - PORT=${PORT:-3000} - - NODE_ENV=${NODE_ENV:-production} - # Admin (initial Super Admin emails, comma-separated) + # Super Admin - SUPER_ADMIN_EMAIL=${SUPER_ADMIN_EMAIL} + # API Key + - API_KEY=${API_KEY} + # MinIO (object storage) + - MINIO_ENDPOINT=${MINIO_ENDPOINT} + - MINIO_PORT=${MINIO_PORT:-443} + - MINIO_USE_SSL=${MINIO_USE_SSL:-true} + - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} + - MINIO_SECRET_KEY=${MINIO_SECRET_KEY} + - MINIO_BUCKET=${MINIO_BUCKET} + - MINIO_UPLOAD_DIR=${MINIO_UPLOAD_DIR:-bug-reports} + # Redis (optional — app logs feature) + - REDIS_URL=${REDIS_URL:-} networks: - public-net - postgres-net-stg From b63117694bd1f9e21df68f41447174636469eefe Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 29 Apr 2026 13:56:23 +0800 Subject: [PATCH 08/16] feat: add /api/system/version endpoint with changelog Mengembalikan versi aplikasi, git commit hash, branch aktif, dan 20 commit terakhir untuk memverifikasi apakah staging/production sudah terupdate. Co-Authored-By: Claude Sonnet 4.6 --- src/app.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/app.ts b/src/app.ts index 5c70a0c..a5a55ad 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1010,6 +1010,39 @@ export function createApp() { tags: ['System'], }, }) + .get('/api/system/version', async () => { + const pkg = await Bun.file('./package.json').json() + let commit = 'unknown' + let branch = 'unknown' + let changelog: { hash: string; date: string; author: string; message: string }[] = [] + try { + const commitProc = Bun.spawn(['git', 'rev-parse', '--short', 'HEAD'], { stdout: 'pipe', stderr: 'pipe' }) + commit = (await new Response(commitProc.stdout).text()).trim() + const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { stdout: 'pipe', stderr: 'pipe' }) + branch = (await new Response(branchProc.stdout).text()).trim() + const logProc = Bun.spawn( + ['git', 'log', '--pretty=format:%h|%aI|%an|%s', '-20'], + { stdout: 'pipe', stderr: 'pipe' }, + ) + const logText = (await new Response(logProc.stdout).text()).trim() + changelog = logText.split('\n').filter(Boolean).map(line => { + const [hash, date, author, ...msgParts] = line.split('|') + return { hash, date, author, message: msgParts.join('|') } + }) + } catch { /* git not available */ } + return { + version: pkg.version as string, + commit, + branch, + changelog, + } + }, { + detail: { + summary: 'Version Info', + description: 'Mengembalikan versi aplikasi, git commit hash, branch aktif, dan 20 commit terakhir sebagai changelog.', + tags: ['System'], + }, + }) // ─── Example API ─────────────────────────────────── .get('/api/hello', () => ({ From ccc43e0c96d9c8299670db0047061bfd54e1a6ce Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 29 Apr 2026 15:47:21 +0800 Subject: [PATCH 09/16] =?UTF-8?q?feat:=20runtime=20config=20via=20DB=20?= =?UTF-8?q?=E2=80=94=20ganti=20VITE=5FURL=5FAPI=5FDESA=5FPLUS=20dengan=20p?= =?UTF-8?q?roxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tambah model AppConfig (key-value) ke schema + migration - Tambah GET/PUT /api/admin/config (DEVELOPER only) - Tambah proxy /api/proxy/desa-plus/* yang baca URL dari DB - Hapus VITE_URL_API_DESA_PLUS dari frontend, ganti semua URL desa-plus ke relative proxy path - Aktifkan Settings tab di /dev dengan UI untuk set URL_API_DESA_PLUS URL desa-plus kini bisa diubah via /dev → Settings tanpa rebuild image. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 2 + .../migration.sql | 8 ++ prisma/schema.prisma | 8 ++ src/app.ts | 50 ++++++++ src/frontend/config/api.ts | 50 ++++---- src/frontend/routes/dev.tsx | 110 +++++++++++++++++- 6 files changed, 197 insertions(+), 31 deletions(-) create mode 100644 prisma/migrations/20260429074454_add_app_config/migration.sql diff --git a/Dockerfile b/Dockerfile index 923dcd9..90fbed2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,8 @@ RUN bunx prisma generate # Build frontend (Vite → dist/) FROM prisma AS builder +ARG VITE_URL_API_DESA_PLUS +ENV VITE_URL_API_DESA_PLUS=$VITE_URL_API_DESA_PLUS COPY . . RUN bun run build diff --git a/prisma/migrations/20260429074454_add_app_config/migration.sql b/prisma/migrations/20260429074454_add_app_config/migration.sql new file mode 100644 index 0000000..5f1770b --- /dev/null +++ b/prisma/migrations/20260429074454_add_app_config/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "app_config" ( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "app_config_pkey" PRIMARY KEY ("key") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 475e633..feb3cab 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -145,6 +145,14 @@ model BugLog { @@map("bug_log") } + +model AppConfig { + key String @id + value String + updatedAt DateTime @updatedAt + + @@map("app_config") +} diff --git a/src/app.ts b/src/app.ts index a5a55ad..5922574 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1552,4 +1552,54 @@ export function createApp() { } return { sessions: result, summary: { totalSessions: result.length, activeSessions: active, expiredSessions: expired, onlineUsers: onlineIds.size, byRole } } }) + + // ─── App Config ──────────────────────────────────────────────────────────── + + .get('/api/admin/config', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const configs = await prisma.appConfig.findMany({ orderBy: { key: 'asc' } }) + return { configs: configs.map((c) => ({ key: c.key, value: c.value, updatedAt: c.updatedAt.toISOString() })) } + }, { + detail: { summary: 'Get App Config', tags: ['Admin'] }, + }) + + .put('/api/admin/config', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const body = await request.json() as { key: string; value: string } + if (!body.key || typeof body.value !== 'string') { set.status = 400; return { error: 'key and value required' } } + const config = await prisma.appConfig.upsert({ + where: { key: body.key }, + update: { value: body.value }, + create: { key: body.key, value: body.value }, + }) + await createSystemLog(auth.userId, 'UPDATE', `Updated app config: ${body.key}`) + return { key: config.key, value: config.value, updatedAt: config.updatedAt.toISOString() } + }, { + detail: { summary: 'Update App Config', tags: ['Admin'] }, + }) + + // ─── Desa Plus Proxy ─────────────────────────────────────────────────────── + + .all('/api/proxy/desa-plus/*', async ({ request, set }) => { + const baseConfig = await prisma.appConfig.findUnique({ where: { key: 'URL_API_DESA_PLUS' } }) + if (!baseConfig?.value) { set.status = 503; return { error: 'URL_API_DESA_PLUS belum dikonfigurasi. Set di /dev → Settings.' } } + const base = baseConfig.value.replace(/\/$/, '') + const url = new URL(request.url) + const upstream = `${base}${url.pathname.replace('/api/proxy/desa-plus', '')}${url.search}` + const headers = new Headers(request.headers) + headers.delete('host') + try { + const res = await fetch(upstream, { method: request.method, headers, body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined }) + const contentType = res.headers.get('content-type') ?? 'application/json' + set.status = res.status + return new Response(res.body, { status: res.status, headers: { 'content-type': contentType } }) + } catch (e) { + set.status = 502 + return { error: 'Gagal menghubungi API desa-plus', detail: String(e) } + } + }, { + detail: { summary: 'Proxy Desa Plus API', tags: ['Proxy'] }, + }) } diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts index 0845254..421aed0 100644 --- a/src/frontend/config/api.ts +++ b/src/frontend/config/api.ts @@ -1,30 +1,30 @@ -export const API_BASE_URL = import.meta.env.VITE_URL_API_DESA_PLUS +const DESA_PLUS_PROXY = '/api/proxy/desa-plus' export const API_URLS = { - getVillages: (page: number, search: string) => - `${API_BASE_URL}/api/monitoring/get-villages?page=${page}&search=${encodeURIComponent(search)}`, - infoVillages: (id: string) => - `${API_BASE_URL}/api/monitoring/info-villages?id=${id}`, - gridVillages: (id: string) => - `${API_BASE_URL}/api/monitoring/grid-villages?id=${id}`, - graphLogVillages: (id: string, time: string) => - `${API_BASE_URL}/api/monitoring/graph-log-villages?id=${id}&time=${time}`, - getUsers: (page: number, search: string) => - `${API_BASE_URL}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`, - getLogsAllVillages: (page: number, search: string) => - `${API_BASE_URL}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`, - getGridOverview: () => `${API_BASE_URL}/api/monitoring/grid-overview`, - getDailyActivity: () => `${API_BASE_URL}/api/monitoring/daily-activity`, - getComparisonActivity: () => `${API_BASE_URL}/api/monitoring/comparison-activity`, - postVersionUpdate: () => `${API_BASE_URL}/api/monitoring/version-update`, - createVillages: () => `${API_BASE_URL}/api/monitoring/create-villages`, - createUser: () => `${API_BASE_URL}/api/monitoring/create-user`, - listRole: () => `${API_BASE_URL}/api/monitoring/list-userrole-villages`, - listGroup: (id: string) => `${API_BASE_URL}/api/monitoring/list-group-villages?id=${id}`, - listPosition: (id: string) => `${API_BASE_URL}/api/monitoring/list-position-villages?id=${id}`, - editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`, - updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`, - editVillages: () => `${API_BASE_URL}/api/monitoring/edit-villages`, + getVillages: (page: number, search: string) => + `${DESA_PLUS_PROXY}/api/monitoring/get-villages?page=${page}&search=${encodeURIComponent(search)}`, + infoVillages: (id: string) => + `${DESA_PLUS_PROXY}/api/monitoring/info-villages?id=${id}`, + gridVillages: (id: string) => + `${DESA_PLUS_PROXY}/api/monitoring/grid-villages?id=${id}`, + graphLogVillages: (id: string, time: string) => + `${DESA_PLUS_PROXY}/api/monitoring/graph-log-villages?id=${id}&time=${time}`, + getUsers: (page: number, search: string) => + `${DESA_PLUS_PROXY}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`, + getLogsAllVillages: (page: number, search: string) => + `${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`, + getGridOverview: () => `${DESA_PLUS_PROXY}/api/monitoring/grid-overview`, + getDailyActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/daily-activity`, + getComparisonActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/comparison-activity`, + postVersionUpdate: () => `${DESA_PLUS_PROXY}/api/monitoring/version-update`, + createVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/create-villages`, + createUser: () => `${DESA_PLUS_PROXY}/api/monitoring/create-user`, + listRole: () => `${DESA_PLUS_PROXY}/api/monitoring/list-userrole-villages`, + listGroup: (id: string) => `${DESA_PLUS_PROXY}/api/monitoring/list-group-villages?id=${id}`, + listPosition: (id: string) => `${DESA_PLUS_PROXY}/api/monitoring/list-position-villages?id=${id}`, + editUser: () => `${DESA_PLUS_PROXY}/api/monitoring/edit-user`, + updateStatusVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/update-status-villages`, + editVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/edit-villages`, getGlobalLogs: (page: number, search: string, type: string, userId: string, dateFrom?: string, dateTo?: string) => { const params = new URLSearchParams({ page: String(page), search, type, userId }) if (dateFrom) params.set('dateFrom', dateFrom) diff --git a/src/frontend/routes/dev.tsx b/src/frontend/routes/dev.tsx index cceceea..8e99dd6 100644 --- a/src/frontend/routes/dev.tsx +++ b/src/frontend/routes/dev.tsx @@ -113,7 +113,7 @@ const navItems = [ // { label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' }, { label: 'Database', icon: TbDatabase, key: 'database' }, { label: 'Project', icon: TbSitemap, key: 'project' }, - // { label: 'Settings', icon: TbSettings, key: 'settings' }, + { label: 'Settings', icon: TbSettings, key: 'settings' }, ] function DevPage() { @@ -1461,15 +1461,113 @@ function StaticFlowPanel({ graph, flowKey }: { graph: { nodes: Node[]; edges: Ed // ─── Settings Panel ──────────────────────────────────────────────────────────── +interface AppConfigEntry { key: string; value: string; updatedAt: string } + +const CONFIG_DEFINITIONS: { key: string; label: string; description: string; placeholder: string }[] = [ + { + key: 'URL_API_DESA_PLUS', + label: 'URL API Desa Plus', + description: 'Base URL untuk API eksternal Desa Plus. Semua request dari frontend akan diproxy melalui server ke URL ini.', + placeholder: 'https://api.desa-plus.example.com', + }, +] + function SettingsPanel() { + const qc = useQueryClient() + const [values, setValues] = useState>({}) + const [saved, setSaved] = useState>({}) + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'config'], + queryFn: () => fetch('/api/admin/config', { credentials: 'include' }).then((r) => r.json()), + }) + + const configs: AppConfigEntry[] = data?.configs ?? [] + + useEffect(() => { + const initial: Record = {} + for (const def of CONFIG_DEFINITIONS) { + const existing = configs.find((c) => c.key === def.key) + initial[def.key] = existing?.value ?? '' + } + setValues(initial) + }, [configs]) + + const saveMutation = useMutation({ + mutationFn: ({ key, value }: { key: string; value: string }) => + fetch('/api/admin/config', { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value }), + }).then((r) => r.json()), + onSuccess: (_, { key }) => { + qc.invalidateQueries({ queryKey: ['admin', 'config'] }) + setSaved((prev) => ({ ...prev, [key]: true })) + setTimeout(() => setSaved((prev) => ({ ...prev, [key]: false })), 2000) + }, + }) + return ( Settings - -
- Konfigurasi sistem akan ditampilkan di sini. -
-
+ Konfigurasi runtime — perubahan langsung berlaku tanpa rebuild atau redeploy. + + {isLoading ?
: ( + + {CONFIG_DEFINITIONS.map((def) => { + const existing = configs.find((c) => c.key === def.key) + return ( + + + +
+ {def.label} + {def.key} +
+ {existing && ( + + Diupdate {new Date(existing.updatedAt).toLocaleString('id-ID')} + + )} +
+ {def.description} + + + setValues((prev) => ({ ...prev, [def.key]: e.target.value }))} + placeholder={def.placeholder} + /> + + + + {!existing && ( + Belum dikonfigurasi — data tidak akan ter-load + )} +
+
+ ) + })} +
+ )}
) } From 8bcb30a85b2d79bd98d31c71947f42dcecf91f10 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 29 Apr 2026 15:51:09 +0800 Subject: [PATCH 10/16] feat: tambah API Key Desa Plus ke Settings panel dan proxy - Tambah kolom API_KEY_DESA_PLUS di CONFIG_DEFINITIONS (ditampilkan sebagai password field) - Proxy otomatis menyertakan X-API-Key header jika API key sudah dikonfigurasi Co-Authored-By: Claude Sonnet 4.6 --- src/app.ts | 6 +++++- src/frontend/routes/dev.tsx | 10 +++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index 5922574..8401f78 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1583,13 +1583,17 @@ export function createApp() { // ─── Desa Plus Proxy ─────────────────────────────────────────────────────── .all('/api/proxy/desa-plus/*', async ({ request, set }) => { - const baseConfig = await prisma.appConfig.findUnique({ where: { key: 'URL_API_DESA_PLUS' } }) + const [baseConfig, apiKeyConfig] = await Promise.all([ + prisma.appConfig.findUnique({ where: { key: 'URL_API_DESA_PLUS' } }), + prisma.appConfig.findUnique({ where: { key: 'API_KEY_DESA_PLUS' } }), + ]) if (!baseConfig?.value) { set.status = 503; return { error: 'URL_API_DESA_PLUS belum dikonfigurasi. Set di /dev → Settings.' } } const base = baseConfig.value.replace(/\/$/, '') const url = new URL(request.url) const upstream = `${base}${url.pathname.replace('/api/proxy/desa-plus', '')}${url.search}` const headers = new Headers(request.headers) headers.delete('host') + if (apiKeyConfig?.value) headers.set('X-API-Key', apiKeyConfig.value) try { const res = await fetch(upstream, { method: request.method, headers, body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined }) const contentType = res.headers.get('content-type') ?? 'application/json' diff --git a/src/frontend/routes/dev.tsx b/src/frontend/routes/dev.tsx index 8e99dd6..f42e028 100644 --- a/src/frontend/routes/dev.tsx +++ b/src/frontend/routes/dev.tsx @@ -1463,13 +1463,20 @@ function StaticFlowPanel({ graph, flowKey }: { graph: { nodes: Node[]; edges: Ed interface AppConfigEntry { key: string; value: string; updatedAt: string } -const CONFIG_DEFINITIONS: { key: string; label: string; description: string; placeholder: string }[] = [ +const CONFIG_DEFINITIONS: { key: string; label: string; description: string; placeholder: string; secret?: boolean }[] = [ { key: 'URL_API_DESA_PLUS', label: 'URL API Desa Plus', description: 'Base URL untuk API eksternal Desa Plus. Semua request dari frontend akan diproxy melalui server ke URL ini.', placeholder: 'https://api.desa-plus.example.com', }, + { + key: 'API_KEY_DESA_PLUS', + label: 'API Key Desa Plus', + description: 'API key untuk autentikasi ke API Desa Plus. Dikirim otomatis sebagai header X-API-Key pada setiap request proxy.', + placeholder: 'your-secret-api-key', + secret: true, + }, ] function SettingsPanel() { @@ -1535,6 +1542,7 @@ function SettingsPanel() { Date: Wed, 29 Apr 2026 15:51:51 +0800 Subject: [PATCH 11/16] upd: gabungkan Settings menjadi 1 form dengan tombol Simpan Semua Co-Authored-By: Claude Sonnet 4.6 --- src/frontend/routes/dev.tsx | 123 +++++++++++++++++++----------------- 1 file changed, 64 insertions(+), 59 deletions(-) diff --git a/src/frontend/routes/dev.tsx b/src/frontend/routes/dev.tsx index f42e028..182c0d2 100644 --- a/src/frontend/routes/dev.tsx +++ b/src/frontend/routes/dev.tsx @@ -1482,7 +1482,7 @@ const CONFIG_DEFINITIONS: { key: string; label: string; description: string; pla function SettingsPanel() { const qc = useQueryClient() const [values, setValues] = useState>({}) - const [saved, setSaved] = useState>({}) + const [saved, setSaved] = useState(false) const { data, isLoading } = useQuery({ queryKey: ['admin', 'config'], @@ -1500,81 +1500,86 @@ function SettingsPanel() { setValues(initial) }, [configs]) - const saveMutation = useMutation({ - mutationFn: ({ key, value }: { key: string; value: string }) => - fetch('/api/admin/config', { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key, value }), - }).then((r) => r.json()), - onSuccess: (_, { key }) => { + const saveAllMutation = useMutation({ + mutationFn: async (vals: Record) => { + await Promise.all( + CONFIG_DEFINITIONS.map((def) => + fetch('/api/admin/config', { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: def.key, value: vals[def.key] ?? '' }), + }) + ) + ) + }, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'config'] }) - setSaved((prev) => ({ ...prev, [key]: true })) - setTimeout(() => setSaved((prev) => ({ ...prev, [key]: false })), 2000) + setSaved(true) + setTimeout(() => setSaved(false), 2000) }, }) + const hasUnconfigured = CONFIG_DEFINITIONS.some((def) => !configs.find((c) => c.key === def.key)) + return ( Settings Konfigurasi runtime — perubahan langsung berlaku tanpa rebuild atau redeploy. {isLoading ?
: ( - - {CONFIG_DEFINITIONS.map((def) => { - const existing = configs.find((c) => c.key === def.key) - return ( - - - + + + {CONFIG_DEFINITIONS.map((def) => { + const existing = configs.find((c) => c.key === def.key) + return ( + +
{def.label} {def.key}
- {existing && ( - - Diupdate {new Date(existing.updatedAt).toLocaleString('id-ID')} - - )} + {existing + ? Diupdate {new Date(existing.updatedAt).toLocaleString('id-ID')} + : Belum dikonfigurasi + }
{def.description} - - - setValues((prev) => ({ ...prev, [def.key]: e.target.value }))} - placeholder={def.placeholder} - /> - - - - {!existing && ( - Belum dikonfigurasi — data tidak akan ter-load - )} + setValues((prev) => ({ ...prev, [def.key]: e.target.value }))} + placeholder={def.placeholder} + />
-
- ) - })} -
+ ) + })} + + {hasUnconfigured && ( + Beberapa konfigurasi belum diisi — data tidak akan ter-load sampai disimpan. + )} + + + + +
+ )}
) From 5050835d81135ce6410ec634a3698f8b4bb0c91f Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 29 Apr 2026 15:54:38 +0800 Subject: [PATCH 12/16] upd: tambah notifikasi success/error saat simpan Settings Co-Authored-By: Claude Sonnet 4.6 --- src/frontend/routes/dev.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/frontend/routes/dev.tsx b/src/frontend/routes/dev.tsx index 182c0d2..e4e2ccf 100644 --- a/src/frontend/routes/dev.tsx +++ b/src/frontend/routes/dev.tsx @@ -70,6 +70,7 @@ import { TbUserSearch, TbUsers, } from 'react-icons/tb' +import { notifications } from '@mantine/notifications' import { type Role, useLogout, useSession } from '@/frontend/hooks/useAuth' import { usePresence } from '@/frontend/hooks/usePresence' @@ -1517,6 +1518,10 @@ function SettingsPanel() { qc.invalidateQueries({ queryKey: ['admin', 'config'] }) setSaved(true) setTimeout(() => setSaved(false), 2000) + notifications.show({ color: 'green', title: 'Tersimpan', message: 'Konfigurasi berhasil disimpan.' }) + }, + onError: () => { + notifications.show({ color: 'red', title: 'Gagal', message: 'Terjadi kesalahan saat menyimpan konfigurasi.' }) }, }) From d3a4f97d0e000b60fe8bb3cc335f41c4dda46901 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 29 Apr 2026 16:01:16 +0800 Subject: [PATCH 13/16] feat: tambah MCP server deploy-stg untuk trigger GitHub workflow - scripts/mcp-deploy.ts: MCP server dengan 2 tool: - publish: trigger publish.yml (build & push image stg) - repull: trigger re-pull.yml (redeploy stack di Portainer) - .mcp.json: registrasi server dengan env GH_TOKEN, STACK_NAME, BASE_URL Co-Authored-By: Claude Sonnet 4.6 --- .mcp.json | 14 ++++ bun.lock | 179 ++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + scripts/mcp-deploy.ts | 49 ++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 .mcp.json create mode 100644 scripts/mcp-deploy.ts diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..8a92383 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "deploy-stg": { + "type": "stdio", + "command": "bun", + "args": ["scripts/mcp-deploy.ts"], + "env": { + "GH_TOKEN": "", + "STACK_NAME": "", + "BASE_URL": "" + } + } + } +} diff --git a/bun.lock b/bun.lock index f80e9bd..c1d46c1 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@mantine/hooks": "^8.3.18", "@mantine/modals": "^8.3.18", "@mantine/notifications": "^8.3.18", + "@modelcontextprotocol/sdk": "^1.29.0", "@prisma/client": "6", "@tanstack/react-query": "^5.95.2", "@tanstack/react-router": "^1.168.10", @@ -185,6 +186,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -213,6 +216,8 @@ "@mantine/store": ["@mantine/store@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-i+QRTLmZzLldea0egtUVnGALd6UMIu8jd44nrNWBSNIXJU/8B6rMlC6gyX+l4szopZSuOaaNJIXkqRdC1gQsVg=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], @@ -363,10 +368,16 @@ "@xyflow/system": ["@xyflow/system@0.0.76", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -403,6 +414,8 @@ "block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="], @@ -413,8 +426,14 @@ "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], "caniuse-lite": ["caniuse-lite@1.0.30001782", "", {}, "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw=="], @@ -441,12 +460,22 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -499,6 +528,8 @@ "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -515,6 +546,10 @@ "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="], "electron-to-chromium": ["electron-to-chromium@1.5.329", "", {}, "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ=="], @@ -527,14 +562,24 @@ "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -543,12 +588,22 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.4.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw=="], + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], @@ -557,8 +612,12 @@ "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="], "fast-xml-parser": ["fast-xml-parser@5.7.1", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA=="], @@ -573,16 +632,28 @@ "filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], @@ -593,12 +664,24 @@ "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "hono": ["hono@4.12.15", "", {}, "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg=="], + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], @@ -621,14 +704,24 @@ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "isbot": ["isbot@5.1.37", "", {}, "sha512-5bcicX81xf6NlTEV8rWdg7Pk01LFizDetuYGHx6d/f6y3lR2/oo8IfxjzJqn1UdDEyCcwT9e7NRloj8DwCYujQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -661,8 +754,14 @@ "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -675,6 +774,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], @@ -687,8 +788,12 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], @@ -697,8 +802,14 @@ "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], @@ -709,6 +820,8 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], @@ -737,6 +850,8 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], @@ -747,8 +862,14 @@ "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], @@ -787,24 +908,48 @@ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="], "seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], @@ -817,6 +962,8 @@ "split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="], "stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="], @@ -859,6 +1006,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -867,6 +1016,8 @@ "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typed-query-selector": ["typed-query-selector@2.12.1", "", {}, "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA=="], "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], @@ -875,6 +1026,8 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -893,6 +1046,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], "vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="], @@ -901,6 +1056,8 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -925,6 +1082,8 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -943,6 +1102,8 @@ "@tanstack/router-utils/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -955,6 +1116,10 @@ "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], @@ -967,14 +1132,20 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], + "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], @@ -989,8 +1160,16 @@ "@scalar/themes/@scalar/types/nanoid": ["nanoid@5.1.9", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw=="], + "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "@kitajs/ts-html-plugin/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "@kitajs/ts-html-plugin/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], diff --git a/package.json b/package.json index 0404f50..f09d44f 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@mantine/hooks": "^8.3.18", "@mantine/modals": "^8.3.18", "@mantine/notifications": "^8.3.18", + "@modelcontextprotocol/sdk": "^1.29.0", "@prisma/client": "6", "@tanstack/react-query": "^5.95.2", "@tanstack/react-router": "^1.168.10", diff --git a/scripts/mcp-deploy.ts b/scripts/mcp-deploy.ts new file mode 100644 index 0000000..8e4b866 --- /dev/null +++ b/scripts/mcp-deploy.ts @@ -0,0 +1,49 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { z } from 'zod' + +const GH_TOKEN = process.env.GH_TOKEN ?? '' +const STACK_NAME = process.env.STACK_NAME ?? '' +const BASE_URL = process.env.BASE_URL ?? '' // e.g. https://api.github.com/repos/org/repo + +async function triggerWorkflow(workflow: string, inputs: Record) { + const res = await fetch(`${BASE_URL}/actions/workflows/${workflow}/dispatches`, { + method: 'POST', + headers: { + Authorization: `Bearer ${GH_TOKEN}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: JSON.stringify({ ref: 'main', inputs }), + }) + if (!res.ok) { + const text = await res.text() + throw new Error(`GitHub API error ${res.status}: ${text}`) + } +} + +const server = new McpServer({ name: 'deploy-stg', version: '1.0.0' }) + +server.tool( + 'publish', + 'Build & push Docker image ke GHCR untuk environment staging (publish.yml)', + { tag: z.string().describe('Image tag, contoh: 1.0.0') }, + async ({ tag }) => { + await triggerWorkflow('publish.yml', { stack_env: 'stg', tag }) + return { content: [{ type: 'text', text: `Workflow publish.yml dipicu untuk stg-${tag}. Cek status di GitHub Actions.` }] } + }, +) + +server.tool( + 'repull', + 'Re-pull dan redeploy stack staging di Portainer (re-pull.yml)', + {}, + async () => { + await triggerWorkflow('re-pull.yml', { stack_name: STACK_NAME, stack_env: 'stg' }) + return { content: [{ type: 'text', text: `Workflow re-pull.yml dipicu untuk stack ${STACK_NAME}-stg. Cek status di GitHub Actions.` }] } + }, +) + +const transport = new StdioServerTransport() +await server.connect(transport) From 7609204a1307ae6f626d49805f2546847a1f580d Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 29 Apr 2026 16:12:22 +0800 Subject: [PATCH 14/16] feat: tambah deploy pipeline tool di MCP deploy-stg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool baru `deploy` menjalankan full pipeline: 1. Cek pending migrations → batalkan jika ada 2. Version bump package.json ke tag baru 3. Commit + push ke build/stg 4. Trigger publish.yml → polling hingga selesai 5. Trigger re-pull.yml → polling hingga selesai 6. Cek version di STG_URL vs local untuk konfirmasi Env baru: STG_URL (staging app URL), VERSION_PATH (default /api/system/version) Co-Authored-By: Claude Sonnet 4.6 --- .mcp.json | 8 +- scripts/mcp-deploy.ts | 212 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 200 insertions(+), 20 deletions(-) diff --git a/.mcp.json b/.mcp.json index 8a92383..f9e5464 100644 --- a/.mcp.json +++ b/.mcp.json @@ -5,9 +5,11 @@ "command": "bun", "args": ["scripts/mcp-deploy.ts"], "env": { - "GH_TOKEN": "", - "STACK_NAME": "", - "BASE_URL": "" + "GH_TOKEN": "${GH_TOKEN}", + "STACK_NAME": "${STACK_NAME}", + "BASE_URL": "${BASE_URL}", + "STG_URL": "${STG_URL}", + "VERSION_PATH": "/api/system/version" } } } diff --git a/scripts/mcp-deploy.ts b/scripts/mcp-deploy.ts index 8e4b866..09fb210 100644 --- a/scripts/mcp-deploy.ts +++ b/scripts/mcp-deploy.ts @@ -2,46 +2,224 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' -const GH_TOKEN = process.env.GH_TOKEN ?? '' -const STACK_NAME = process.env.STACK_NAME ?? '' -const BASE_URL = process.env.BASE_URL ?? '' // e.g. https://api.github.com/repos/org/repo +const GH_TOKEN = process.env.GH_TOKEN ?? '' +const STACK_NAME = process.env.STACK_NAME ?? '' +const BASE_URL = process.env.BASE_URL ?? '' // https://api.github.com/repos/owner/repo +const STG_URL = process.env.STG_URL ?? '' // https://monitoring-stg.example.com +const VERSION_PATH = process.env.VERSION_PATH ?? '/api/system/version' + +// ─── GitHub API helpers ──────────────────────────────────────────────────────── + +const ghHeaders = { + Authorization: `Bearer ${GH_TOKEN}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', +} async function triggerWorkflow(workflow: string, inputs: Record) { const res = await fetch(`${BASE_URL}/actions/workflows/${workflow}/dispatches`, { method: 'POST', - headers: { - Authorization: `Bearer ${GH_TOKEN}`, - Accept: 'application/vnd.github+json', - 'Content-Type': 'application/json', - 'X-GitHub-Api-Version': '2022-11-28', - }, + headers: ghHeaders, body: JSON.stringify({ ref: 'main', inputs }), }) - if (!res.ok) { - const text = await res.text() - throw new Error(`GitHub API error ${res.status}: ${text}`) - } + if (!res.ok) throw new Error(`GitHub API error ${res.status}: ${await res.text()}`) } +async function waitForWorkflow( + workflow: string, + afterTime: Date, + timeoutMs = 600_000, +): Promise<{ conclusion: string; url: string }> { + const deadline = Date.now() + timeoutMs + await Bun.sleep(8_000) // tunggu run muncul di API + + while (Date.now() < deadline) { + const res = await fetch(`${BASE_URL}/actions/workflows/${workflow}/runs?per_page=5`, { + headers: ghHeaders, + }) + const data = await res.json() as { workflow_runs: any[] } + const run = data.workflow_runs?.find( + (r: any) => new Date(r.created_at) >= afterTime, + ) + + if (run) { + if (run.status === 'completed') { + return { conclusion: run.conclusion ?? 'failure', url: run.html_url } + } + } + + await Bun.sleep(12_000) + } + + throw new Error(`Workflow ${workflow} timeout setelah ${timeoutMs / 1000}s`) +} + +// ─── Shell helpers ───────────────────────────────────────────────────────────── + +async function sh(cmd: string[]): Promise<{ out: string; err: string; ok: boolean }> { + const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe', cwd: process.cwd() }) + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + return { out: out.trim(), err: err.trim(), ok: code === 0 } +} + +// ─── MCP Server ──────────────────────────────────────────────────────────────── + const server = new McpServer({ name: 'deploy-stg', version: '1.0.0' }) +// ─── Tool: publish (manual, single step) ────────────────────────────────────── + server.tool( 'publish', - 'Build & push Docker image ke GHCR untuk environment staging (publish.yml)', + 'Trigger publish.yml untuk build & push Docker image staging', { tag: z.string().describe('Image tag, contoh: 1.0.0') }, async ({ tag }) => { await triggerWorkflow('publish.yml', { stack_env: 'stg', tag }) - return { content: [{ type: 'text', text: `Workflow publish.yml dipicu untuk stg-${tag}. Cek status di GitHub Actions.` }] } + return { content: [{ type: 'text', text: `✅ publish.yml dipicu → stg-${tag}` }] } }, ) +// ─── Tool: repull (manual, single step) ─────────────────────────────────────── + server.tool( 'repull', - 'Re-pull dan redeploy stack staging di Portainer (re-pull.yml)', + 'Trigger re-pull.yml untuk redeploy stack staging di Portainer', {}, async () => { await triggerWorkflow('re-pull.yml', { stack_name: STACK_NAME, stack_env: 'stg' }) - return { content: [{ type: 'text', text: `Workflow re-pull.yml dipicu untuk stack ${STACK_NAME}-stg. Cek status di GitHub Actions.` }] } + return { content: [{ type: 'text', text: `✅ re-pull.yml dipicu → ${STACK_NAME}-stg` }] } + }, +) + +// ─── Tool: deploy (full pipeline) ───────────────────────────────────────────── + +server.tool( + 'deploy', + [ + 'Full deploy pipeline ke staging:', + '1. Cek pending migrations', + '2. Version bump di package.json', + '3. Commit & push ke build/stg', + '4. Trigger publish.yml → tunggu selesai', + '5. Trigger re-pull.yml → tunggu selesai', + '6. Cek version di staging & local untuk konfirmasi', + ].join('\n'), + { tag: z.string().describe('Versi baru, contoh: 1.2.3') }, + async ({ tag }) => { + const log: string[] = [] + + // ── 1. Cek migrasi ────────────────────────────────────────────────────── + const migrate = await sh(['bunx', 'prisma', 'migrate', 'status']) + if (!migrate.ok || migrate.out.includes('not yet been applied')) { + return { + content: [{ + type: 'text', + text: [ + '❌ Deploy dibatalkan — ada pending migrations.', + '', + migrate.out || migrate.err, + '', + 'Jalankan `bun run db:migrate` terlebih dahulu.', + ].join('\n'), + }], + } + } + log.push('✅ Migrations: up to date') + + // ── 2. Version bump ────────────────────────────────────────────────────── + const pkgPath = `${process.cwd()}/package.json` + const pkg = await Bun.file(pkgPath).json() + const prevVersion = pkg.version as string + pkg.version = tag + await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + '\n') + log.push(`✅ Version bump: ${prevVersion} → ${tag}`) + + // ── 3. Commit & push build/stg ─────────────────────────────────────────── + await sh(['git', 'add', 'package.json']) + const commit = await sh(['git', 'commit', '-m', `chore: bump version to ${tag}`]) + if (!commit.ok) { + return { content: [{ type: 'text', text: `❌ git commit gagal:\n${commit.err}` }] } + } + log.push('✅ Committed') + + const push = await sh(['git', 'push', 'origin', 'HEAD:build/stg']) + if (!push.ok) { + return { content: [{ type: 'text', text: `❌ git push gagal:\n${push.err}` }] } + } + log.push('✅ Pushed → build/stg') + + // ── 4. Publish workflow ────────────────────────────────────────────────── + log.push('⏳ Menjalankan publish.yml...') + const publishTriggeredAt = new Date() + await triggerWorkflow('publish.yml', { stack_env: 'stg', tag }) + + const publish = await waitForWorkflow('publish.yml', publishTriggeredAt) + if (publish.conclusion !== 'success') { + return { + content: [{ + type: 'text', + text: [ + ...log, + `❌ publish.yml ${publish.conclusion}`, + `Detail: ${publish.url}`, + ].join('\n'), + }], + } + } + log.push(`✅ publish.yml sukses → ${publish.url}`) + + // ── 5. Re-pull workflow ────────────────────────────────────────────────── + log.push('⏳ Menjalankan re-pull.yml...') + const repullTriggeredAt = new Date() + await triggerWorkflow('re-pull.yml', { stack_name: STACK_NAME, stack_env: 'stg' }) + + const repull = await waitForWorkflow('re-pull.yml', repullTriggeredAt) + if (repull.conclusion !== 'success') { + return { + content: [{ + type: 'text', + text: [ + ...log, + `❌ re-pull.yml ${repull.conclusion}`, + `Detail: ${repull.url}`, + ].join('\n'), + }], + } + } + log.push(`✅ re-pull.yml sukses → ${repull.url}`) + + // ── 6. Cek version ─────────────────────────────────────────────────────── + await Bun.sleep(5_000) // tunggu container restart + log.push('⏳ Mengecek version di staging...') + + const localCommitProc = await sh(['git', 'rev-parse', '--short', 'HEAD']) + const localCommit = localCommitProc.out + + let stgInfo: { version?: string; commit?: string } = {} + try { + const versionRes = await fetch(`${STG_URL}${VERSION_PATH}`) + stgInfo = await versionRes.json() + } catch (e) { + log.push(`⚠️ Gagal mengecek version staging: ${e}`) + } + + const versionMatch = stgInfo.version === tag + const commitMatch = stgInfo.commit === localCommit + + log.push('') + log.push('─── Version Check ───────────────────────────') + log.push(`Local : version=${tag}, commit=${localCommit}`) + log.push(`Staging: version=${stgInfo.version ?? '?'}, commit=${stgInfo.commit ?? '?'}`) + log.push(versionMatch && commitMatch + ? '✅ Staging sudah terupdate dan sesuai local' + : `⚠️ Mismatch — version: ${versionMatch ? 'OK' : 'BEDA'}, commit: ${commitMatch ? 'OK' : 'BEDA'}`, + ) + + return { content: [{ type: 'text', text: log.join('\n') }] } }, ) From f44a8216bf810105e3054220f2e56c4a26e7df33 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 29 Apr 2026 16:26:35 +0800 Subject: [PATCH 15/16] fix: jalankan prisma migrate deploy otomatis jika ada pending migrations Sebelumnya pipeline dibatalkan saat ada pending migrations. Sekarang langsung deploy migrations lalu lanjut ke step berikutnya. Co-Authored-By: Claude Sonnet 4.6 --- scripts/mcp-deploy.ts | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/scripts/mcp-deploy.ts b/scripts/mcp-deploy.ts index 09fb210..5b9c4f0 100644 --- a/scripts/mcp-deploy.ts +++ b/scripts/mcp-deploy.ts @@ -112,23 +112,27 @@ server.tool( async ({ tag }) => { const log: string[] = [] - // ── 1. Cek migrasi ────────────────────────────────────────────────────── - const migrate = await sh(['bunx', 'prisma', 'migrate', 'status']) - if (!migrate.ok || migrate.out.includes('not yet been applied')) { - return { - content: [{ - type: 'text', - text: [ - '❌ Deploy dibatalkan — ada pending migrations.', - '', - migrate.out || migrate.err, - '', - 'Jalankan `bun run db:migrate` terlebih dahulu.', - ].join('\n'), - }], + // ── 1. Cek & jalankan migrasi jika ada ───────────────────────────────── + const migrateStatus = await sh(['bunx', 'prisma', 'migrate', 'status']) + if (!migrateStatus.ok || migrateStatus.out.includes('not yet been applied')) { + log.push('⏳ Ada pending migrations — menjalankan migrate deploy...') + const migrateRun = await sh(['bunx', 'prisma', 'migrate', 'deploy']) + if (!migrateRun.ok) { + return { + content: [{ + type: 'text', + text: [ + ...log, + '❌ Migrate deploy gagal:', + migrateRun.err || migrateRun.out, + ].join('\n'), + }], + } } + log.push('✅ Migrations: deployed') + } else { + log.push('✅ Migrations: up to date') } - log.push('✅ Migrations: up to date') // ── 2. Version bump ────────────────────────────────────────────────────── const pkgPath = `${process.cwd()}/package.json` From a2c7be7cfa1cf1735f28cbe99bc5f14f360646f2 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 29 Apr 2026 16:38:19 +0800 Subject: [PATCH 16/16] chore: bump version to 0.1.1 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index f09d44f..807f277 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "bun-react-template", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { + "claude": "set -a && source .env && set +a && claude", "dev": "bun --watch src/serve.ts", "build": "vite build", "start": "NODE_ENV=production bun src/index.tsx",