Merged changes from qt0ebghx/main

Replit-Task-Id: e786be21-972b-4d23-bbe7-9eb4ae617f7b
This commit is contained in:
amertensreplit 2026-06-11 05:23:53 +00:00
parent e54b0528be
commit 4a7607d3a5
34 changed files with 1573 additions and 90 deletions

View file

@ -5,3 +5,4 @@
- [Testing api-server from shell](api-server-local-curl.md) — external `$REPLIT_DEV_DOMAIN/api` curl returns HTTP 000; curl `http://localhost:<PORT>/api` instead (port from workflow log). - [Testing api-server from shell](api-server-local-curl.md) — external `$REPLIT_DEV_DOMAIN/api` curl returns HTTP 000; curl `http://localhost:<PORT>/api` instead (port from workflow log).
- [Stale codegen & unapplied migrations](skillguard-stale-codegen-and-migrations.md) — "field already in API" tasks: dev/test DB + lib `dist/*.d.ts` lag; run drizzle push + `tsc -b` the lib. - [Stale codegen & unapplied migrations](skillguard-stale-codegen-and-migrations.md) — "field already in API" tasks: dev/test DB + lib `dist/*.d.ts` lag; run drizzle push + `tsc -b` the lib.
- [Mocking fetch in api-server route tests](api-server-fetch-mocking-in-tests.md) — route tests run app in-process; delegate localhost requests to real fetch, only synthesize upstream; filter spy calls by URL. - [Mocking fetch in api-server route tests](api-server-fetch-mocking-in-tests.md) — route tests run app in-process; delegate localhost requests to real fetch, only synthesize upstream; filter spy calls by URL.
- [Clerk shadcn theme + Tailwind v4](clerk-shadcn-theme-tailwind.md) — Clerk shadcn.css needs `optimize:false` + explicit `@layer` order or sign-in/up widgets render unstyled.

View file

@ -0,0 +1,13 @@
---
name: Clerk shadcn theme + Tailwind v4
description: Why Clerk's shadcn.css theme needs Tailwind optimize:false and a layer order in a Vite app
---
When wiring Clerk components to match a shadcn/ui design (via `@import "@clerk/themes/shadcn.css"`), two non-obvious settings are required in a Tailwind v4 + Vite app:
- `index.css` must declare the cascade layer order explicitly, e.g. `@layer theme, base, clerk, components, utilities;` before importing the Clerk theme, so Clerk styles land in the right layer and don't get overridden by (or override) app styles.
- `vite.config.ts` must set `tailwindcss({ optimize: false })`.
**Why:** Tailwind v4's CSS optimizer/minifier prunes the Clerk theme's selectors (it can't see Clerk's runtime-generated class usage), so with optimization on the Clerk sign-in/sign-up widgets render unstyled. Disabling optimize keeps the imported theme intact.
**How to apply:** Any artifact that themes Clerk widgets through the shadcn theme import. Symptom if missing: Clerk `<SignIn/>`/`<SignUp/>` appear unstyled or mismatched despite the import being present.

View file

@ -47,6 +47,10 @@ externalPort = 80
localPort = 8081 localPort = 8081
externalPort = 8081 externalPort = 8081
[[ports]]
localPort = 8082
externalPort = 3001
[[ports]] [[ports]]
localPort = 20892 localPort = 20892
externalPort = 3000 externalPort = 3000

View file

@ -11,13 +11,17 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@clerk/express": "^2.1.23",
"@clerk/shared": "^4.15.0",
"@workspace/api-zod": "workspace:*", "@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*", "@workspace/db": "workspace:*",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.6", "cors": "^2.8.6",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"http-proxy-middleware": "^4.1.0",
"pino": "^9.14.0", "pino": "^9.14.0",
"pino-http": "^10.5.0" "pino-http": "^10.5.0"
}, },

View file

@ -1,11 +1,21 @@
import express, { type Express } from "express"; import express, { type Express } from "express";
import cors from "cors"; import cors from "cors";
import pinoHttp from "pino-http"; import pinoHttp from "pino-http";
import { clerkMiddleware } from "@clerk/express";
import { publishableKeyFromHost } from "@clerk/shared/keys";
import {
CLERK_PROXY_PATH,
clerkProxyMiddleware,
getClerkProxyHost,
} from "./middlewares/clerkProxyMiddleware";
import router from "./routes"; import router from "./routes";
import { logger } from "./lib/logger"; import { logger } from "./lib/logger";
const app: Express = express(); const app: Express = express();
// Trust the Replit proxy so req.ip reflects the client for rate limiting.
app.set("trust proxy", 1);
app.use( app.use(
pinoHttp({ pinoHttp({
logger, logger,
@ -25,10 +35,27 @@ app.use(
}, },
}), }),
); );
app.use(cors());
// Clerk Frontend API proxy must be mounted before the body parsers since it
// streams raw bytes. Active in production only.
app.use(CLERK_PROXY_PATH, clerkProxyMiddleware());
app.use(cors({ credentials: true, origin: true }));
app.use(express.json({ limit: "25mb" })); app.use(express.json({ limit: "25mb" }));
app.use(express.urlencoded({ extended: true, limit: "25mb" })); app.use(express.urlencoded({ extended: true, limit: "25mb" }));
// Resolve the publishable key from the incoming request host so the same
// server can serve multiple Clerk custom domains. Falls back to
// CLERK_PUBLISHABLE_KEY when the host doesn't map to a custom domain.
app.use(
clerkMiddleware((req) => ({
publishableKey: publishableKeyFromHost(
getClerkProxyHost(req) ?? "",
process.env.CLERK_PUBLISHABLE_KEY,
),
})),
);
app.use("/api", router); app.use("/api", router);
export default app; export default app;

View file

@ -0,0 +1,75 @@
import type { Request, Response, NextFunction } from "express";
import { getAuth, clerkClient } from "@clerk/express";
import { logger } from "../lib/logger";
/**
* Admin access is restricted to an allowlist of email addresses configured via
* the ADMIN_EMAILS environment variable (comma-separated, case-insensitive).
* Signing in alone never grants admin access the email must be on the list.
*/
export function getAdminAllowlist(): string[] {
return (process.env.ADMIN_EMAILS ?? "")
.split(",")
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
}
export type AuthInfo = {
userId: string | null;
email: string | null;
isAdmin: boolean;
};
/**
* Resolve the caller's authentication state without throwing. Anonymous
* visitors short-circuit before any Clerk API call so public endpoints stay
* fast; only signed-in users incur a user lookup to read their email.
*/
export async function resolveAuth(req: Request): Promise<AuthInfo> {
const auth = getAuth(req);
const userId = auth?.userId ?? null;
if (!userId) return { userId: null, email: null, isAdmin: false };
let email: string | null = null;
try {
const user = await clerkClient.users.getUser(userId);
email =
user.primaryEmailAddress?.emailAddress ??
user.emailAddresses[0]?.emailAddress ??
null;
} catch (err) {
logger.error({ err }, "Clerk-Benutzer konnte nicht geladen werden");
email = null;
}
const allowlist = getAdminAllowlist();
const isAdmin = !!email && allowlist.includes(email.toLowerCase());
return { userId, email, isAdmin };
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
auth?: AuthInfo;
}
}
}
export async function requireAdmin(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const info = await resolveAuth(req);
if (!info.userId) {
res.status(401).json({ error: "Nicht angemeldet." });
return;
}
if (!info.isAdmin) {
res.status(403).json({ error: "Kein Administratorzugriff." });
return;
}
req.auth = info;
next();
}

View file

@ -0,0 +1,91 @@
/**
* Clerk Frontend API Proxy Middleware
*
* Proxies Clerk Frontend API requests through your domain, enabling Clerk
* authentication on custom domains and .replit.app deployments without
* requiring CNAME DNS configuration.
*
* AUTH CONFIGURATION: To manage users, enable/disable login providers
* (Google, GitHub, etc.), change app branding, or configure OAuth credentials,
* use the Auth pane in the workspace toolbar. There is no external Clerk
* dashboard all auth configuration is done through the Auth pane.
*
* IMPORTANT:
* - Only active in production (Clerk proxying doesn't work for dev instances)
* - Must be mounted BEFORE express.json() middleware
*
* Usage in app.ts:
* import { CLERK_PROXY_PATH, clerkProxyMiddleware } from "./middlewares/clerkProxyMiddleware";
* app.use(CLERK_PROXY_PATH, clerkProxyMiddleware());
*/
import { createProxyMiddleware } from "http-proxy-middleware";
import type { RequestHandler } from "express";
import type { IncomingHttpHeaders } from "http";
const CLERK_FAPI = "https://frontend-api.clerk.dev";
export const CLERK_PROXY_PATH = "/api/__clerk";
/**
* Returns the first effective public hostname for the given request,
* preferring x-forwarded-host over the Host header so callers behind a
* proxy see the original client-facing host.
*
* x-forwarded-host can take three shapes:
* - undefined (no proxy involved)
* - a single string (one proxy hop)
* - a comma-delimited string when an upstream appended rather than
* replaced the header (Node folds duplicate headers this way), or a
* string[] in some Express typings
* In the multi-value case, the leftmost value is the original client-
* facing host. Take that one in all forms. Exported so that app.ts
* (clerkMiddleware callback) and this proxy middleware agree on which
* hostname is canonical otherwise multi-domain/custom-domain flows
* break.
*/
export function getClerkProxyHost(req: {
headers: IncomingHttpHeaders;
}): string | undefined {
const forwarded = req.headers["x-forwarded-host"];
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const firstHop = raw?.split(",")[0]?.trim();
return firstHop || req.headers.host?.trim() || undefined;
}
export function clerkProxyMiddleware(): RequestHandler {
// Only run proxy in production — Clerk proxying doesn't work for dev instances
if (process.env.NODE_ENV !== "production") {
return (_req, _res, next) => next();
}
const secretKey = process.env.CLERK_SECRET_KEY;
if (!secretKey) {
return (_req, _res, next) => next();
}
return createProxyMiddleware({
target: CLERK_FAPI,
changeOrigin: true,
pathRewrite: (path: string) =>
path.replace(new RegExp(`^${CLERK_PROXY_PATH}`), ""),
on: {
proxyReq: (proxyReq, req) => {
const protocol = req.headers["x-forwarded-proto"] || "https";
const host = getClerkProxyHost(req) || "";
const proxyUrl = `${protocol}://${host}${CLERK_PROXY_PATH}`;
proxyReq.setHeader("Clerk-Proxy-Url", proxyUrl);
proxyReq.setHeader("Clerk-Secret-Key", secretKey);
const xff = req.headers["x-forwarded-for"];
const clientIp =
(Array.isArray(xff) ? xff[0] : xff)?.split(",")[0]?.trim() ||
req.socket?.remoteAddress ||
"";
if (clientIp) {
proxyReq.setHeader("X-Forwarded-For", clientIp);
}
},
},
}) as RequestHandler;
}

View file

@ -0,0 +1,18 @@
import { Router, type IRouter } from "express";
import { GetMeResponse } from "@workspace/api-zod";
import { resolveAuth } from "../middlewares/auth";
const router: IRouter = Router();
router.get("/me", async (req, res) => {
const info = await resolveAuth(req);
res.json(
GetMeResponse.parse({
authenticated: !!info.userId,
isAdmin: info.isAdmin,
email: info.email,
}),
);
});
export default router;

View file

@ -1,18 +1,32 @@
import { Router, type IRouter } from "express"; import { Router, type IRouter } from "express";
import healthRouter from "./health"; import healthRouter from "./health";
import authRouter from "./auth";
import dashboardRouter from "./dashboard"; import dashboardRouter from "./dashboard";
import scansRouter from "./scans"; import scansRouter from "./scans";
import providersRouter from "./providers"; import providersRouter from "./providers";
import promptsRouter from "./prompts"; import promptsRouter from "./prompts";
import rulesRouter from "./rules"; import rulesRouter from "./rules";
import { requireAdmin } from "../middlewares/auth";
const router: IRouter = Router(); const router: IRouter = Router();
// Public endpoints (no login required).
router.use(healthRouter); router.use(healthRouter);
router.use(dashboardRouter); router.use(authRouter);
// Scans router owns its own auth: public list/report/download/create, but
// admin-only delete and moderation. Rules expose a public GET with an
// admin-only PATCH (enforced inside the router).
router.use(scansRouter); router.use(scansRouter);
router.use(providersRouter);
router.use(promptsRouter);
router.use(rulesRouter); router.use(rulesRouter);
// Admin-only resources: the entire /providers, /prompts and /dashboard
// surfaces require an allowlisted admin. Path-scoped so public routes above
// are never gated.
router.use("/providers", requireAdmin);
router.use("/prompts", requireAdmin);
router.use("/dashboard", requireAdmin);
router.use(providersRouter);
router.use(promptsRouter);
router.use(dashboardRouter);
export default router; export default router;

View file

@ -9,6 +9,24 @@ import {
} from "vitest"; } from "vitest";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import type { Server } from "node:http"; import type { Server } from "node:http";
// The /providers routes are admin-gated. These tests exercise the route logic
// itself, not the Clerk allowlist, so we stub the auth middleware to grant
// admin access. Auth enforcement is covered separately.
vi.mock("../middlewares/auth", () => ({
getAdminAllowlist: () => ["admin@test.local"],
resolveAuth: async () => ({
userId: "test-admin",
email: "admin@test.local",
isAdmin: true,
}),
requireAdmin: (
_req: unknown,
_res: unknown,
next: () => void,
) => next(),
}));
import app from "../app"; import app from "../app";
import { db, pool, aiProvidersTable } from "@workspace/db"; import { db, pool, aiProvidersTable } from "@workspace/db";
import { inArray } from "drizzle-orm"; import { inArray } from "drizzle-orm";

View file

@ -8,6 +8,7 @@ import {
UpdateRuleBody, UpdateRuleBody,
UpdateRuleResponse, UpdateRuleResponse,
} from "@workspace/api-zod"; } from "@workspace/api-zod";
import { requireAdmin } from "../middlewares/auth";
const router: IRouter = Router(); const router: IRouter = Router();
@ -30,7 +31,7 @@ router.get("/rules", async (_req, res) => {
res.json(ListRulesResponse.parse(rows.map(serializeRule))); res.json(ListRulesResponse.parse(rows.map(serializeRule)));
}); });
router.patch("/rules/:id", async (req, res) => { router.patch("/rules/:id", requireAdmin, async (req, res) => {
const params = UpdateRuleParams.safeParse(req.params); const params = UpdateRuleParams.safeParse(req.params);
if (!params.success) return res.status(400).json({ message: "Ungültige ID" }); if (!params.success) return res.status(400).json({ message: "Ungültige ID" });
const parsed = UpdateRuleBody.safeParse(req.body); const parsed = UpdateRuleBody.safeParse(req.body);

View file

@ -13,6 +13,8 @@ import {
type Prompt, type Prompt,
} from "@workspace/db"; } from "@workspace/db";
import { eq, desc, count } from "drizzle-orm"; import { eq, desc, count } from "drizzle-orm";
import rateLimit from "express-rate-limit";
import { zipSync, strToU8 } from "fflate";
import { import {
ListScansResponse, ListScansResponse,
CreateScanBody, CreateScanBody,
@ -22,7 +24,11 @@ import {
CompareScansParams, CompareScansParams,
CompareScansResponse, CompareScansResponse,
GetScanLineageResponse, GetScanLineageResponse,
ModerateScanParams,
ModerateScanBody,
ModerateScanResponse,
} from "@workspace/api-zod"; } from "@workspace/api-zod";
import { resolveAuth, requireAdmin } from "../middlewares/auth";
import { import {
parseUpload, parseUpload,
parseText, parseText,
@ -56,10 +62,23 @@ export function serializeScan(scan: Scan) {
relation: scan.relation, relation: scan.relation,
similarity: scan.similarity, similarity: scan.similarity,
comparedScanId: scan.comparedScanId, comparedScanId: scan.comparedScanId,
hidden: scan.hidden,
createdAt: scan.createdAt.toISOString(), createdAt: scan.createdAt.toISOString(),
}; };
} }
// Public scan creation is rate-limited per client to curb abuse of the open
// upload/test endpoints. Admin and read endpoints are unaffected.
const scanRateLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 10,
standardHeaders: true,
legacyHeaders: false,
message: {
message: "Zu viele Scans in kurzer Zeit. Bitte später erneut versuchen.",
},
});
function serializeFile(f: ScanFile) { function serializeFile(f: ScanFile) {
return { return {
path: f.path, path: f.path,
@ -413,15 +432,19 @@ async function persistScan(
return { scan, files: insertedFiles, findings: insertedFindings }; return { scan, files: insertedFiles, findings: insertedFindings };
} }
router.get("/scans", async (_req, res) => { router.get("/scans", async (req, res) => {
// Public visitors only see the released catalog; admins also see hidden scans
// so they can manage moderation.
const info = await resolveAuth(req);
const rows = await db const rows = await db
.select() .select()
.from(scansTable) .from(scansTable)
.where(info.isAdmin ? undefined : eq(scansTable.hidden, false))
.orderBy(desc(scansTable.createdAt)); .orderBy(desc(scansTable.createdAt));
res.json(ListScansResponse.parse(rows.map(serializeScan))); res.json(ListScansResponse.parse(rows.map(serializeScan)));
}); });
router.post("/scans", async (req, res) => { router.post("/scans", scanRateLimiter, async (req, res) => {
const parsed = CreateScanBody.safeParse(req.body); const parsed = CreateScanBody.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
return res return res
@ -453,7 +476,7 @@ router.post("/scans", async (req, res) => {
const STREAM_PACING_MS = 80; const STREAM_PACING_MS = 80;
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
router.post("/scans/stream", async (req, res) => { router.post("/scans/stream", scanRateLimiter, async (req, res) => {
const parsed = CreateScanBody.safeParse(req.body); const parsed = CreateScanBody.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
res res
@ -544,6 +567,13 @@ router.get("/scans/:id", async (req, res) => {
.where(eq(scansTable.id, params.data.id)); .where(eq(scansTable.id, params.data.id));
if (!scan) return res.status(404).json({ message: "Scan nicht gefunden" }); if (!scan) return res.status(404).json({ message: "Scan nicht gefunden" });
// Hidden scans are invisible to the public; only admins can open the report.
if (scan.hidden) {
const info = await resolveAuth(req);
if (!info.isAdmin)
return res.status(404).json({ message: "Scan nicht gefunden" });
}
const files = await db const files = await db
.select() .select()
.from(scanFilesTable) .from(scanFilesTable)
@ -557,6 +587,87 @@ router.get("/scans/:id", async (req, res) => {
return res.json(GetScanResponse.parse(await buildScanDetail(scan, files, findings))); return res.json(GetScanResponse.parse(await buildScanDetail(scan, files, findings)));
}); });
// Public download of a skill that PASSED. Bundles the stored text files back
// into a ZIP. Binary files were never persisted, so they are omitted. Blocked
// for non-pass verdicts and for hidden scans (unless the caller is an admin).
function safeFilename(name: string): string {
const cleaned = name
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return cleaned || "skill";
}
router.get("/scans/:id/download", async (req, res) => {
const params = GetScanParams.safeParse(req.params);
if (!params.success)
return res.status(400).json({ message: "Ungültige ID" });
const [scan] = await db
.select()
.from(scansTable)
.where(eq(scansTable.id, params.data.id));
if (!scan) return res.status(404).json({ message: "Scan nicht gefunden" });
if (scan.hidden) {
const info = await resolveAuth(req);
if (!info.isAdmin)
return res.status(404).json({ message: "Scan nicht gefunden" });
}
if (scan.verdict !== "pass") {
return res.status(403).json({
message:
"Nur Skills mit dem Ergebnis „Bestanden“ können heruntergeladen werden.",
});
}
const files = await db
.select()
.from(scanFilesTable)
.where(eq(scanFilesTable.scanId, scan.id));
const entries: Record<string, Uint8Array> = {};
for (const f of files) {
if (f.content === null) continue; // binary content was not stored
entries[f.path] = strToU8(f.content);
}
if (Object.keys(entries).length === 0) {
return res.status(404).json({
message: "Für dieses Skill sind keine herunterladbaren Dateien gespeichert.",
});
}
const zipped = zipSync(entries, { level: 6 });
const filename = `${safeFilename(scan.name)}.zip`;
res.setHeader("Content-Type", "application/zip");
res.setHeader(
"Content-Disposition",
`attachment; filename="${filename}"`,
);
return res.send(Buffer.from(zipped));
});
router.patch("/scans/:id", requireAdmin, async (req, res) => {
const params = ModerateScanParams.safeParse(req.params);
if (!params.success)
return res.status(400).json({ message: "Ungültige ID" });
const parsed = ModerateScanBody.safeParse(req.body);
if (!parsed.success)
return res
.status(400)
.json({ message: "Ungültige Eingabe", details: parsed.error.issues });
const [updated] = await db
.update(scansTable)
.set({ hidden: parsed.data.hidden })
.where(eq(scansTable.id, params.data.id))
.returning();
if (!updated) return res.status(404).json({ message: "Scan nicht gefunden" });
return res.json(ModerateScanResponse.parse(serializeScan(updated)));
});
router.get("/scans/:id/compare/:otherId", async (req, res) => { router.get("/scans/:id/compare/:otherId", async (req, res) => {
const params = CompareScansParams.safeParse(req.params); const params = CompareScansParams.safeParse(req.params);
if (!params.success) if (!params.success)
@ -729,7 +840,7 @@ router.get("/scans/:id/lineage", async (req, res) => {
return res.json(GetScanLineageResponse.parse(entries)); return res.json(GetScanLineageResponse.parse(entries));
}); });
router.delete("/scans/:id", async (req, res) => { router.delete("/scans/:id", requireAdmin, async (req, res) => {
const params = DeleteScanParams.safeParse(req.params); const params = DeleteScanParams.safeParse(req.params);
if (!params.success) if (!params.success)
return res.status(400).json({ message: "Ungültige ID" }); return res.status(400).json({ message: "Ungültige ID" });

View file

@ -73,5 +73,9 @@
"vite": "catalog:", "vite": "catalog:",
"wouter": "^3.3.5", "wouter": "^3.3.5",
"zod": "catalog:" "zod": "catalog:"
},
"dependencies": {
"@clerk/react": "^6.7.3",
"@clerk/themes": "^2.4.57"
} }
} }

View file

@ -0,0 +1,5 @@
<svg width="160" height="40" viewBox="0 0 160 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 4L33 9V19C33 27.5 27.4 33.6 20 36C12.6 33.6 7 27.5 7 19V9L20 4Z" fill="hsl(215 25% 27%)"/>
<path d="M14.5 19.5L18.5 23.5L26 16" stroke="white" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
<text x="42" y="26" font-family="Inter, sans-serif" font-size="19" font-weight="700" fill="hsl(222 47% 11%)">SkillGuard</text>
</svg>

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View file

@ -1,12 +1,17 @@
import { Switch, Route, Router as WouterRouter } from "wouter"; import { useEffect, useRef } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ClerkProvider, SignIn, SignUp, useClerk } from "@clerk/react";
import { publishableKeyFromHost } from "@clerk/react/internal";
import { shadcn } from "@clerk/themes";
import { Switch, Route, useLocation, Router as WouterRouter } from "wouter";
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { AppLayout } from "@/components/layout";
import { PublicLayout } from "@/components/public-layout"; import { PublicLayout } from "@/components/public-layout";
import { AppLayout } from "@/components/layout";
import { RequireAdmin } from "@/components/require-admin";
import NotFound from "@/pages/not-found"; import NotFound from "@/pages/not-found";
import Landing from "@/pages/landing"; import Catalog from "@/pages/catalog";
import Dashboard from "@/pages/dashboard"; import Dashboard from "@/pages/dashboard";
import ScanForm from "@/pages/scan-form"; import ScanForm from "@/pages/scan-form";
import ScanReport from "@/pages/scan-report"; import ScanReport from "@/pages/scan-report";
@ -16,45 +21,223 @@ import Admin from "@/pages/admin";
import Impressum from "@/pages/impressum"; import Impressum from "@/pages/impressum";
import Haftungsausschluss from "@/pages/haftungsausschluss"; import Haftungsausschluss from "@/pages/haftungsausschluss";
// REQUIRED — copy verbatim. Resolves the key from window.location.hostname so the
// same build serves multiple Clerk custom domains.
const clerkPubKey = publishableKeyFromHost(
window.location.hostname,
import.meta.env.VITE_CLERK_PUBLISHABLE_KEY,
);
// REQUIRED — copy verbatim. Empty in dev, auto-set in prod.
const clerkProxyUrl = import.meta.env.VITE_CLERK_PROXY_URL;
const basePath = import.meta.env.BASE_URL.replace(/\/$/, "");
// Clerk passes full paths to routerPush/routerReplace, but wouter's
// setLocation prepends the base — strip it to avoid doubling.
function stripBase(path: string): string {
return basePath && path.startsWith(basePath) ? path.slice(basePath.length) || "/" : path;
}
if (!clerkPubKey) {
throw new Error("Missing VITE_CLERK_PUBLISHABLE_KEY in .env file");
}
const queryClient = new QueryClient(); const queryClient = new QueryClient();
function Router() { const clerkAppearance = {
theme: shadcn,
cssLayerName: "clerk",
options: {
logoPlacement: "inside" as const,
logoLinkUrl: basePath || "/",
logoImageUrl: `${window.location.origin}${basePath}/logo.svg`,
},
variables: {
colorPrimary: "hsl(215, 25%, 27%)",
colorForeground: "hsl(222, 47%, 11%)",
colorMutedForeground: "hsl(215, 16%, 47%)",
colorDanger: "hsl(0, 84%, 60%)",
colorBackground: "hsl(0, 0%, 100%)",
colorInput: "hsl(0, 0%, 100%)",
colorInputForeground: "hsl(222, 47%, 11%)",
colorNeutral: "hsl(214, 32%, 91%)",
fontFamily: "'Inter', sans-serif",
borderRadius: "0.5rem",
},
elements: {
rootBox: "w-full flex justify-center",
cardBox: "bg-white rounded-2xl w-[440px] max-w-full overflow-hidden shadow-lg border border-slate-200",
card: "!shadow-none !border-0 !bg-transparent !rounded-none",
footer: "!shadow-none !border-0 !bg-transparent !rounded-none",
headerTitle: "text-slate-900 font-bold",
headerSubtitle: "text-slate-500",
socialButtonsBlockButtonText: "text-slate-700 font-medium",
formFieldLabel: "text-slate-700 font-medium",
footerActionLink: "text-slate-800 font-semibold hover:text-slate-900",
footerActionText: "text-slate-500",
dividerText: "text-slate-400",
identityPreviewEditButton: "text-slate-700",
formFieldSuccessText: "text-emerald-600",
alertText: "text-slate-700",
logoBox: "h-9",
logoImage: "h-9 w-auto",
socialButtonsBlockButton: "border border-slate-200 hover:bg-slate-50",
formButtonPrimary: "bg-slate-800 hover:bg-slate-900 text-white",
formFieldInput: "border border-slate-200 bg-white text-slate-900",
footerAction: "text-slate-500",
dividerLine: "bg-slate-200",
alert: "border border-slate-200 bg-slate-50",
otpCodeFieldInput: "border border-slate-200 text-slate-900",
formFieldRow: "gap-2",
main: "gap-4",
},
};
function SignInPage() {
return ( return (
<Switch> <div className="flex min-h-[100dvh] items-center justify-center bg-background px-4">
<Route path="/"> <SignIn routing="path" path={`${basePath}/sign-in`} signUpUrl={`${basePath}/sign-up`} />
<PublicLayout> </div>
<Landing /> );
</PublicLayout> }
</Route>
<Route> function SignUpPage() {
<AppLayout> return (
<div className="flex min-h-[100dvh] items-center justify-center bg-background px-4">
<SignUp routing="path" path={`${basePath}/sign-up`} signInUrl={`${basePath}/sign-in`} />
</div>
);
}
// Keeps the cache fresh when the signed-in user changes.
function ClerkQueryClientCacheInvalidator() {
const { addListener } = useClerk();
const qc = useQueryClient();
const prevUserIdRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
const unsubscribe = addListener(({ user }) => {
const userId = user?.id ?? null;
if (prevUserIdRef.current !== undefined && prevUserIdRef.current !== userId) {
qc.clear();
}
prevUserIdRef.current = userId;
});
return unsubscribe;
}, [addListener, qc]);
return null;
}
function ClerkProviderWithRoutes() {
const [, setLocation] = useLocation();
return (
<ClerkProvider
publishableKey={clerkPubKey}
proxyUrl={clerkProxyUrl}
appearance={clerkAppearance}
signInUrl={`${basePath}/sign-in`}
signUpUrl={`${basePath}/sign-up`}
localization={{
signIn: {
start: {
title: "SkillGuard Administration",
subtitle: "Melden Sie sich an, um den Administrationsbereich zu öffnen.",
},
},
signUp: {
start: {
title: "Konto erstellen",
subtitle: "Registrieren Sie sich für den Administrationsbereich.",
},
},
}}
routerPush={(to) => setLocation(stripBase(to))}
routerReplace={(to) => setLocation(stripBase(to), { replace: true })}
>
<QueryClientProvider client={queryClient}>
<ClerkQueryClientCacheInvalidator />
<TooltipProvider>
<Switch> <Switch>
<Route path="/dashboard" component={Dashboard} /> {/* Public area */}
<Route path="/pruefen" component={ScanForm} /> <Route path="/">
<Route path="/berichte/:id" component={ScanReport} /> <PublicLayout>
<Route path="/vergleich/:id/:otherId" component={ScanCompare} /> <Catalog />
<Route path="/verlauf" component={ScanHistory} /> </PublicLayout>
<Route path="/admin" component={Admin} /> </Route>
<Route path="/impressum" component={Impressum} /> <Route path="/pruefen">
<Route path="/haftungsausschluss" component={Haftungsausschluss} /> <PublicLayout>
<Route component={NotFound} /> <ScanForm />
</PublicLayout>
</Route>
<Route path="/berichte/:id">
<PublicLayout>
<ScanReport />
</PublicLayout>
</Route>
<Route path="/vergleich/:id/:otherId">
<PublicLayout>
<ScanCompare />
</PublicLayout>
</Route>
<Route path="/impressum">
<PublicLayout>
<Impressum />
</PublicLayout>
</Route>
<Route path="/haftungsausschluss">
<PublicLayout>
<Haftungsausschluss />
</PublicLayout>
</Route>
{/* Auth */}
<Route path="/sign-in/*?" component={SignInPage} />
<Route path="/sign-up/*?" component={SignUpPage} />
{/* Admin back office */}
<Route path="/admin">
<RequireAdmin>
<AppLayout>
<Dashboard />
</AppLayout>
</RequireAdmin>
</Route>
<Route path="/admin/verlauf">
<RequireAdmin>
<AppLayout>
<ScanHistory />
</AppLayout>
</RequireAdmin>
</Route>
<Route path="/admin/einstellungen">
<RequireAdmin>
<AppLayout>
<Admin />
</AppLayout>
</RequireAdmin>
</Route>
<Route>
<PublicLayout>
<NotFound />
</PublicLayout>
</Route>
</Switch> </Switch>
</AppLayout> <Toaster />
</Route> </TooltipProvider>
</Switch> </QueryClientProvider>
</ClerkProvider>
); );
} }
function App() { function App() {
return ( return (
<QueryClientProvider client={queryClient}> <WouterRouter base={basePath}>
<TooltipProvider> <ClerkProviderWithRoutes />
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}> </WouterRouter>
<Router />
</WouterRouter>
<Toaster />
</TooltipProvider>
</QueryClientProvider>
); );
} }

View file

@ -1,61 +1,68 @@
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { Shield, LayoutDashboard, Search, History, Settings } from "lucide-react"; import { Shield, LayoutDashboard, History, Settings, LogOut, ExternalLink } from "lucide-react";
import { Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarProvider, SidebarGroup, SidebarGroupContent, SidebarGroupLabel } from "@/components/ui/sidebar"; import { useClerk, useUser } from "@clerk/react";
import { Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarProvider, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarFooter } from "@/components/ui/sidebar";
const basePath = import.meta.env.BASE_URL.replace(/\/$/, "");
export function AppLayout({ children }: { children: React.ReactNode }) { export function AppLayout({ children }: { children: React.ReactNode }) {
const [location] = useLocation(); const [location] = useLocation();
const { signOut } = useClerk();
const { user } = useUser();
return ( return (
<SidebarProvider> <SidebarProvider>
<div className="flex min-h-screen w-full bg-background text-foreground"> <div className="flex min-h-screen w-full bg-background text-foreground">
<Sidebar className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground"> <Sidebar className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground">
<SidebarHeader className="p-4"> <SidebarHeader className="p-4 flex flex-row items-center gap-2">
<Link href="/" className="flex flex-row items-center gap-2"> <Shield className="w-6 h-6 text-sidebar-primary" />
<Shield className="w-6 h-6 text-sidebar-primary" /> <div className="flex flex-col">
<span className="font-bold text-lg tracking-tight">SkillGuard</span> <span className="font-bold text-lg tracking-tight leading-none">SkillGuard</span>
</Link> <span className="text-xs text-sidebar-foreground/50">Administration</span>
</div>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel className="text-sidebar-foreground/50">Navigation</SidebarGroupLabel> <SidebarGroupLabel className="text-sidebar-foreground/50">Verwaltung</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.startsWith("/dashboard")}> <SidebarMenuButton asChild isActive={location === "/admin"}>
<Link href="/dashboard"> <Link href="/admin">
<LayoutDashboard className="w-4 h-4 mr-2" /> <LayoutDashboard className="w-4 h-4 mr-2" />
<span>Dashboard</span> <span>Dashboard</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild isActive={location === "/pruefen"}> <SidebarMenuButton asChild isActive={location.startsWith("/admin/verlauf")}>
<Link href="/pruefen"> <Link href="/admin/verlauf">
<Search className="w-4 h-4 mr-2" /> <History className="w-4 h-4 mr-2" />
<span>Skill Prüfen</span> <span>Verlauf</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.startsWith("/verlauf")}> <SidebarMenuButton asChild isActive={location.startsWith("/admin/einstellungen")}>
<Link href="/verlauf"> <Link href="/admin/einstellungen">
<History className="w-4 h-4 mr-2" /> <Settings className="w-4 h-4 mr-2" />
<span>Verlauf</span> <span>Konfiguration</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<SidebarGroup className="mt-auto"> <SidebarGroup>
<SidebarGroupLabel className="text-sidebar-foreground/50">Öffentlich</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.startsWith("/admin")}> <SidebarMenuButton asChild>
<Link href="/admin"> <Link href="/">
<Settings className="w-4 h-4 mr-2" /> <ExternalLink className="w-4 h-4 mr-2" />
<span>Administration</span> <span>Zum Katalog</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@ -63,6 +70,22 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter className="p-4 border-t border-sidebar-border">
{user && (
<div className="mb-2 px-1 text-xs text-sidebar-foreground/60 truncate">
{user.primaryEmailAddress?.emailAddress ?? "Angemeldet"}
</div>
)}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton onClick={() => signOut({ redirectUrl: basePath || "/" })}>
<LogOut className="w-4 h-4 mr-2" />
<span>Abmelden</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar> </Sidebar>
<main className="flex-1 flex flex-col h-screen overflow-hidden"> <main className="flex-1 flex flex-col h-screen overflow-hidden">

View file

@ -1,10 +1,18 @@
import { Link } from "wouter"; import { Link, useLocation } from "wouter";
import { Shield, Search, LayoutDashboard } from "lucide-react"; import { Shield, Search, ShieldCheck, Settings, LayoutDashboard } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const NAV = [
{ href: "/", label: "Katalog", match: (l: string) => l === "/" },
{ href: "/pruefen", label: "Skill prüfen", match: (l: string) => l.startsWith("/pruefen") },
];
export function PublicLayout({ children }: { children: React.ReactNode }) { export function PublicLayout({ children }: { children: React.ReactNode }) {
const [location] = useLocation();
return ( return (
<div className="flex min-h-screen w-full flex-col bg-background text-foreground"> <div className="flex min-h-screen flex-col bg-background text-foreground">
<header className="sticky top-0 z-30 border-b border-border bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-30 border-b border-border bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 sm:px-6"> <div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 sm:px-6">
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
@ -12,28 +20,39 @@ export function PublicLayout({ children }: { children: React.ReactNode }) {
<span className="text-lg font-bold tracking-tight">SkillGuard</span> <span className="text-lg font-bold tracking-tight">SkillGuard</span>
</Link> </Link>
<nav className="flex items-center gap-2"> <nav className="flex items-center gap-2">
<Button asChild variant="ghost" size="sm"> {NAV.map((item) => (
<Link href="/dashboard"> <Button
<LayoutDashboard className="mr-2 h-4 w-4" /> key={item.href}
<span className="hidden sm:inline">Zum Dashboard</span> asChild
<span className="sm:hidden">Dashboard</span> variant={item.match(location) ? "secondary" : "ghost"}
</Link> size="sm"
</Button> >
<Button asChild size="sm"> <Link href={item.href}>
<Link href="/pruefen"> {item.label}
<Search className="mr-2 h-4 w-4" /> </Link>
Skill prüfen </Button>
))}
<Button asChild variant="outline" size="sm" className="ml-1">
<Link href="/admin">
<Settings className="mr-1.5 h-4 w-4" />
<span className="hidden sm:inline">Administration</span>
<span className="sm:hidden">Admin</span>
</Link> </Link>
</Button> </Button>
</nav> </nav>
</div> </div>
</header> </header>
<main className="flex-1">{children}</main> <main className="flex-1">
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">{children}</div>
</main>
<footer className="border-t border-border"> <footer className="border-t border-border bg-background">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-2 px-4 py-6 text-xs text-muted-foreground sm:flex-row sm:px-6"> <div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 px-4 py-6 text-xs text-muted-foreground sm:flex-row sm:px-6">
<span>© 2026 avameo GmbH</span> <div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-primary" />
<span>© 2026 avameo GmbH</span>
</div>
<nav className="flex items-center gap-4"> <nav className="flex items-center gap-4">
<Link href="/impressum" className="transition-colors hover:text-foreground"> <Link href="/impressum" className="transition-colors hover:text-foreground">
Impressum Impressum

View file

@ -0,0 +1,74 @@
import { Link } from "wouter";
import { useGetMe } from "@workspace/api-client-react";
import { Loader2, ShieldX, LogIn } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
/**
* Gates admin-only screens. Reads GET /api/me (cookie-based Clerk session) to
* decide access: unauthenticated visitors are invited to sign in, signed-in
* users who are not on the ADMIN_EMAILS allowlist are refused, and only
* allowlisted admins see the wrapped content.
*/
export function RequireAdmin({ children }: { children: React.ReactNode }) {
const { data, isLoading } = useGetMe();
if (isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!data?.authenticated) {
return (
<div className="flex min-h-[70vh] items-center justify-center p-4">
<Card className="w-full max-w-md text-center">
<CardHeader>
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<LogIn className="h-6 w-6 text-primary" />
</div>
<CardTitle>Anmeldung erforderlich</CardTitle>
<CardDescription>
Der Administrationsbereich ist geschützt. Bitte melden Sie sich an, um fortzufahren.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Button asChild>
<Link href="/sign-in">Anmelden</Link>
</Button>
<Button asChild variant="ghost">
<Link href="/">Zurück zum Katalog</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
if (!data.isAdmin) {
return (
<div className="flex min-h-[70vh] items-center justify-center p-4">
<Card className="w-full max-w-md text-center">
<CardHeader>
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<ShieldX className="h-6 w-6 text-destructive" />
</div>
<CardTitle>Kein Zugriff</CardTitle>
<CardDescription>
Ihr Konto{data.email ? ` (${data.email})` : ""} ist nicht für den Administrationsbereich freigeschaltet.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link href="/">Zurück zum Katalog</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
return <>{children}</>;
}

View file

@ -1,4 +1,6 @@
@layer theme, base, clerk, components, utilities;
@import "tailwindcss"; @import "tailwindcss";
@import "@clerk/themes/shadcn.css";
@import "tw-animate-css"; @import "tw-animate-css";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";

View file

@ -0,0 +1,170 @@
import { useState, useMemo } from "react";
import { Link } from "wouter";
import { useListScans } from "@workspace/api-client-react";
import type { Scan } from "@workspace/api-client-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { VerdictBadge } from "@/components/ui-helpers";
import { formatDate } from "@/lib/format";
import { Shield, Search, Download, ArrowRight, FileSearch, ShieldCheck } from "lucide-react";
export default function Catalog() {
const { data, isLoading } = useListScans();
const [search, setSearch] = useState("");
const [verdict, setVerdict] = useState<string>("all");
const scans = useMemo(() => (data ?? []) as Scan[], [data]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return scans.filter((s) => {
if (verdict !== "all" && s.verdict !== verdict) return false;
if (q && !s.name.toLowerCase().includes(q) && !(s.description ?? "").toLowerCase().includes(q)) return false;
return true;
});
}, [scans, search, verdict]);
return (
<div className="space-y-10">
<section className="relative overflow-hidden rounded-2xl border border-border bg-gradient-to-br from-primary to-slate-700 px-6 py-12 text-primary-foreground sm:px-12 sm:py-16">
<div className="absolute -right-10 -top-10 opacity-10">
<Shield className="h-64 w-64" />
</div>
<div className="relative max-w-2xl space-y-5">
<div className="inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-xs font-medium">
<ShieldCheck className="h-3.5 w-3.5" />
Sicherheits- und Datenschutzprüfung für KI-Skills
</div>
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
Geprüfte Skills. Transparente Berichte.
</h1>
<p className="text-base text-primary-foreground/80 sm:text-lg">
Durchsuchen Sie den Katalog automatisiert geprüfter Skills, lesen Sie die ausführlichen
Sicherheitsberichte oder lassen Sie Ihren eigenen Skill kostenlos analysieren.
</p>
<div className="flex flex-wrap gap-3 pt-1">
<Button asChild size="lg" variant="secondary" className="gap-2">
<Link href="/pruefen">
<FileSearch className="h-4 w-4" />
Skill prüfen
</Link>
</Button>
</div>
</div>
</section>
<section className="space-y-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Skill-Katalog</h2>
<p className="text-sm text-muted-foreground">
{scans.length} {scans.length === 1 ? "geprüfter Skill" : "geprüfte Skills"} verfügbar
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Skill suchen …"
className="pl-9 sm:w-64"
/>
</div>
<Select value={verdict} onValueChange={setVerdict}>
<SelectTrigger className="sm:w-44">
<SelectValue placeholder="Bewertung" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Bewertungen</SelectItem>
<SelectItem value="pass">Unauffällig</SelectItem>
<SelectItem value="review">Manuelle Prüfung</SelectItem>
<SelectItem value="block">Blockiert</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{isLoading ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-48 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border bg-card py-16 text-center">
<FileSearch className="h-12 w-12 text-muted-foreground opacity-50" />
<h3 className="text-lg font-semibold">Keine Skills gefunden</h3>
<p className="max-w-md text-sm text-muted-foreground">
{scans.length === 0
? "Es wurden noch keine Skills geprüft. Prüfen Sie als Erster einen Skill."
: "Für die aktuelle Suche bzw. Filter gibt es keine Treffer."}
</p>
<Button asChild variant="outline" className="mt-2 gap-2">
<Link href="/pruefen">
<FileSearch className="h-4 w-4" />
Skill prüfen
</Link>
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((scan) => (
<Card key={scan.id} className="flex flex-col transition-shadow hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base leading-snug">
<Link href={`/berichte/${scan.id}`} className="hover:underline">
{scan.name || `Scan #${scan.id}`}
</Link>
</CardTitle>
<VerdictBadge verdict={scan.verdict} />
</div>
<p className="text-xs text-muted-foreground">{formatDate(scan.createdAt)}</p>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4">
<p className="line-clamp-3 flex-1 text-sm text-muted-foreground">
{scan.description || "Keine Beschreibung verfügbar."}
</p>
<div className="flex items-center justify-between">
<span
className={
"text-sm font-medium " +
(scan.riskScore < 30
? "text-emerald-600"
: scan.riskScore < 70
? "text-amber-600"
: "text-rose-600")
}
>
Risiko {scan.riskScore} / 100
</span>
<div className="flex items-center gap-1">
{scan.verdict === "pass" && (
<Button asChild size="sm" variant="outline" className="gap-1.5">
<a href={`/api/scans/${scan.id}/download`} download>
<Download className="h-3.5 w-3.5" />
Download
</a>
</Button>
)}
<Button asChild size="sm" variant="ghost" className="gap-1">
<Link href={`/berichte/${scan.id}`}>
Bericht
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</section>
</div>
);
}

View file

@ -1,7 +1,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link } from "wouter"; import { Link } from "wouter";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useListScans, getListScansQueryKey, useDeleteScan } from "@workspace/api-client-react"; import { useListScans, getListScansQueryKey, useDeleteScan, useModerateScan } from "@workspace/api-client-react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@ -11,7 +11,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { VerdictBadge, RelationBadge } from "@/components/ui-helpers"; import { VerdictBadge, RelationBadge } from "@/components/ui-helpers";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { Search, Trash2, ArrowRight, X } from "lucide-react"; import { Search, Trash2, ArrowRight, X, EyeOff, Eye } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
const VERDICT_OPTIONS = [ const VERDICT_OPTIONS = [
@ -30,6 +30,7 @@ export default function ScanHistory() {
const { data: scans, isLoading } = useListScans(); const { data: scans, isLoading } = useListScans();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const deleteScan = useDeleteScan(); const deleteScan = useDeleteScan();
const moderateScan = useModerateScan();
const { toast } = useToast(); const { toast } = useToast();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@ -70,6 +71,23 @@ export default function ScanHistory() {
}); });
}; };
const handleToggleHidden = (id: number, hidden: boolean) => {
moderateScan.mutate({ id, data: { hidden } }, {
onSuccess: () => {
toast({
title: hidden ? "Aus Katalog entfernt" : "Im Katalog sichtbar",
description: hidden
? "Der Skill wird im öffentlichen Katalog nicht mehr angezeigt."
: "Der Skill ist wieder im öffentlichen Katalog sichtbar.",
});
queryClient.invalidateQueries({ queryKey: getListScansQueryKey() });
},
onError: () => {
toast({ title: "Fehler", description: "Die Sichtbarkeit konnte nicht geändert werden.", variant: "destructive" });
},
});
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -188,6 +206,11 @@ export default function ScanHistory() {
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span className="font-semibold text-lg">{scan.name || `Scan #${scan.id}`}</span> <span className="font-semibold text-lg">{scan.name || `Scan #${scan.id}`}</span>
<VerdictBadge verdict={scan.verdict} /> <VerdictBadge verdict={scan.verdict} />
{scan.hidden && (
<Badge variant="outline" className="gap-1 border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
<EyeOff className="w-3 h-3" /> Ausgeblendet
</Badge>
)}
{scan.relation && scan.relation !== "new" && <RelationBadge relation={scan.relation} />} {scan.relation && scan.relation !== "new" && <RelationBadge relation={scan.relation} />}
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
@ -225,7 +248,18 @@ export default function ScanHistory() {
</div> </div>
</Link> </Link>
<div className="border-t sm:border-t-0 sm:border-l p-4 flex items-center justify-center bg-slate-50 dark:bg-slate-900/50"> <div className="border-t sm:border-t-0 sm:border-l p-4 flex items-center justify-center gap-1 bg-slate-50 dark:bg-slate-900/50">
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleToggleHidden(scan.id, !scan.hidden)}
disabled={moderateScan.isPending}
title={scan.hidden ? "Im Katalog anzeigen" : "Aus Katalog ausblenden"}
aria-label={scan.hidden ? "Im Katalog anzeigen" : "Aus Katalog ausblenden"}
>
{scan.hidden ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</Button>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"> <Button variant="ghost" size="icon" className="text-muted-foreground hover:text-destructive hover:bg-destructive/10">

View file

@ -7,6 +7,8 @@ import {
useGetScanLineage, useGetScanLineage,
getGetScanLineageQueryKey, getGetScanLineageQueryKey,
useGenerateScanDescription, useGenerateScanDescription,
useGetMe,
useModerateScan,
} from "@workspace/api-client-react"; } from "@workspace/api-client-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@ -21,7 +23,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers"; import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles, Loader2, Folder, File as FileIcon, Copy, Check, ChevronRight, ChevronDown } from "lucide-react"; import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles, Loader2, Folder, File as FileIcon, Copy, Check, ChevronRight, ChevronDown, EyeOff, Eye, FileArchive } from "lucide-react";
import type { ScanDetail } from "@workspace/api-client-react"; import type { ScanDetail } from "@workspace/api-client-react";
type ScanReportFile = ScanDetail["files"][number]; type ScanReportFile = ScanDetail["files"][number];
@ -245,6 +247,26 @@ export default function ScanReport() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { toast } = useToast(); const { toast } = useToast();
const { data: me } = useGetMe();
const isAdmin = me?.isAdmin ?? false;
const moderateScan = useModerateScan({
mutation: {
onSuccess: (updated) => {
queryClient.setQueryData(getGetScanQueryKey(updated.id), (prev: ScanDetail | undefined) =>
prev ? { ...prev, hidden: updated.hidden } : prev,
);
toast({
title: updated.hidden ? "Aus Katalog entfernt" : "Im Katalog sichtbar",
description: updated.hidden
? "Der Skill wird im öffentlichen Katalog nicht mehr angezeigt."
: "Der Skill ist wieder im öffentlichen Katalog sichtbar.",
});
},
onError: () => {
toast({ title: "Fehler", description: "Die Sichtbarkeit konnte nicht geändert werden.", variant: "destructive" });
},
},
});
const generateDescription = useGenerateScanDescription({ const generateDescription = useGenerateScanDescription({
mutation: { mutation: {
onSuccess: (updated) => { onSuccess: (updated) => {
@ -351,7 +373,32 @@ export default function ScanReport() {
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button onClick={handleExportPdf} variant="default" className="gap-2"> {data.verdict === "pass" && (!data.hidden || isAdmin) && (
<Button asChild variant="default" className="gap-2">
<a href={`/api/scans/${data.id}/download`} download>
<FileArchive className="w-4 h-4" />
Skill herunterladen
</a>
</Button>
)}
{isAdmin && (
<Button
onClick={() => moderateScan.mutate({ id: data.id, data: { hidden: !data.hidden } })}
disabled={moderateScan.isPending}
variant="outline"
className="gap-2"
>
{moderateScan.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : data.hidden ? (
<Eye className="w-4 h-4" />
) : (
<EyeOff className="w-4 h-4" />
)}
{data.hidden ? "Im Katalog anzeigen" : "Aus Katalog ausblenden"}
</Button>
)}
<Button onClick={handleExportPdf} variant="outline" className="gap-2">
<FileDown className="w-4 h-4" /> <FileDown className="w-4 h-4" />
Als PDF exportieren Als PDF exportieren
</Button> </Button>

View file

@ -30,7 +30,7 @@ export default defineConfig({
base: basePath, base: basePath,
plugins: [ plugins: [
react(), react(),
tailwindcss(), tailwindcss({ optimize: false }),
runtimeErrorOverlay(), runtimeErrorOverlay(),
...(process.env.NODE_ENV !== "production" && ...(process.env.NODE_ENV !== "production" &&
process.env.REPL_ID !== undefined process.env.REPL_ID !== undefined

View file

@ -13,6 +13,21 @@ export interface ApiError {
error: string; error: string;
} }
export interface AuthMe {
authenticated: boolean;
isAdmin: boolean;
/**
* The signed-in user's primary email, when available
* @nullable
*/
email?: string | null;
}
export interface ScanModerationUpdate {
/** Whether to hide the scan from the public catalog */
hidden: boolean;
}
export type SkillScanInputSource = typeof SkillScanInputSource[keyof typeof SkillScanInputSource]; export type SkillScanInputSource = typeof SkillScanInputSource[keyof typeof SkillScanInputSource];
@ -132,6 +147,8 @@ export interface Scan {
* @nullable * @nullable
*/ */
comparedScanId: number | null; comparedScanId: number | null;
/** Whether an admin has hidden this scan from the public catalog */
hidden: boolean;
createdAt: string; createdAt: string;
} }

View file

@ -24,6 +24,7 @@ import type {
AiProviderInput, AiProviderInput,
AiProviderUpdate, AiProviderUpdate,
ApiError, ApiError,
AuthMe,
DashboardSummary, DashboardSummary,
HealthStatus, HealthStatus,
Prompt, Prompt,
@ -38,6 +39,7 @@ import type {
ScanComparison, ScanComparison,
ScanDetail, ScanDetail,
ScanLineageEntry, ScanLineageEntry,
ScanModerationUpdate,
SkillScanInput SkillScanInput
} from './api.schemas'; } from './api.schemas';
@ -131,6 +133,84 @@ export function useHealthCheck<TData = Awaited<ReturnType<typeof healthCheck>>,
export const getGetMeUrl = () => {
return `/api/me`
}
/**
* Returns whether the caller is signed in and whether their email is on the admin allowlist. Always 200; never requires auth.
* @summary Current authentication and admin status
*/
export const getMe = async ( options?: RequestInit): Promise<AuthMe> => {
return customFetch<AuthMe>(getGetMeUrl(),
{
...options,
method: 'GET'
}
);}
export const getGetMeQueryKey = () => {
return [
`/api/me`
] as const;
}
export const getGetMeQueryOptions = <TData = Awaited<ReturnType<typeof getMe>>, TError = ErrorType<unknown>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getMe>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetMeQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMe>>> = ({ signal }) => getMe({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getMe>>, TError, TData> & { queryKey: QueryKey }
}
export type GetMeQueryResult = NonNullable<Awaited<ReturnType<typeof getMe>>>
export type GetMeQueryError = ErrorType<unknown>
/**
* @summary Current authentication and admin status
*/
export function useGetMe<TData = Awaited<ReturnType<typeof getMe>>, TError = ErrorType<unknown>>(
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getMe>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMeQueryOptions(options)
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey };
}
export const getGetDashboardUrl = () => { export const getGetDashboardUrl = () => {
@ -596,6 +676,79 @@ export function useGetScan<TData = Awaited<ReturnType<typeof getScan>>, TError =
export const getModerateScanUrl = (id: number,) => {
return `/api/scans/${id}`
}
/**
* Admin-only. Toggles whether a scan appears in the public catalog.
* @summary Moderate a scan (hide or unhide from the public catalog)
*/
export const moderateScan = async (id: number,
scanModerationUpdate: ScanModerationUpdate, options?: RequestInit): Promise<Scan> => {
return customFetch<Scan>(getModerateScanUrl(id),
{
...options,
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
scanModerationUpdate,)
}
);}
export const getModerateScanMutationOptions = <TError = ErrorType<ApiError>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof moderateScan>>, TError,{id: number;data: BodyType<ScanModerationUpdate>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof moderateScan>>, TError,{id: number;data: BodyType<ScanModerationUpdate>}, TContext> => {
const mutationKey = ['moderateScan'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof moderateScan>>, {id: number;data: BodyType<ScanModerationUpdate>}> = (props) => {
const {id,data} = props ?? {};
return moderateScan(id,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type ModerateScanMutationResult = NonNullable<Awaited<ReturnType<typeof moderateScan>>>
export type ModerateScanMutationBody = BodyType<ScanModerationUpdate>
export type ModerateScanMutationError = ErrorType<ApiError>
/**
* @summary Moderate a scan (hide or unhide from the public catalog)
*/
export const useModerateScan = <TError = ErrorType<ApiError>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof moderateScan>>, TError,{id: number;data: BodyType<ScanModerationUpdate>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationResult<
Awaited<ReturnType<typeof moderateScan>>,
TError,
{id: number;data: BodyType<ScanModerationUpdate>},
TContext
> => {
return useMutation(getModerateScanMutationOptions(options));
}
export const getDeleteScanUrl = (id: number,) => { export const getDeleteScanUrl = (id: number,) => {

View file

@ -10,6 +10,8 @@ servers:
tags: tags:
- name: health - name: health
description: Health operations description: Health operations
- name: auth
description: Authentication status
- name: scans - name: scans
description: Skill scans and audit reports description: Skill scans and audit reports
- name: providers - name: providers
@ -35,6 +37,22 @@ paths:
schema: schema:
$ref: "#/components/schemas/HealthStatus" $ref: "#/components/schemas/HealthStatus"
/me:
get:
operationId: getMe
tags: [auth]
summary: Current authentication and admin status
description: >-
Returns whether the caller is signed in and whether their email is on
the admin allowlist. Always 200; never requires auth.
responses:
"200":
description: Authentication status
content:
application/json:
schema:
$ref: "#/components/schemas/AuthMe"
/dashboard: /dashboard:
get: get:
operationId: getDashboard operationId: getDashboard
@ -181,6 +199,36 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/ApiError" $ref: "#/components/schemas/ApiError"
patch:
operationId: moderateScan
tags: [scans]
summary: Moderate a scan (hide or unhide from the public catalog)
description: Admin-only. Toggles whether a scan appears in the public catalog.
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ScanModerationUpdate"
responses:
"200":
description: Updated scan
content:
application/json:
schema:
$ref: "#/components/schemas/Scan"
"404":
description: Not found
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
delete: delete:
operationId: deleteScan operationId: deleteScan
tags: [scans] tags: [scans]
@ -454,6 +502,26 @@ components:
error: error:
type: string type: string
AuthMe:
type: object
required: [authenticated, isAdmin]
properties:
authenticated:
type: boolean
isAdmin:
type: boolean
email:
type: ["string", "null"]
description: The signed-in user's primary email, when available
ScanModerationUpdate:
type: object
required: [hidden]
properties:
hidden:
type: boolean
description: Whether to hide the scan from the public catalog
SkillScanInput: SkillScanInput:
type: object type: object
required: [source, useAi] required: [source, useAi]
@ -493,6 +561,7 @@ components:
- relation - relation
- similarity - similarity
- comparedScanId - comparedScanId
- hidden
- createdAt - createdAt
properties: properties:
id: id:
@ -534,6 +603,9 @@ components:
comparedScanId: comparedScanId:
type: ["integer", "null"] type: ["integer", "null"]
description: The scan this one was compared against, if any description: The scan this one was compared against, if any
hidden:
type: boolean
description: Whether an admin has hidden this scan from the public catalog
createdAt: createdAt:
type: string type: string

View file

@ -17,6 +17,17 @@ export const HealthCheckResponse = zod.object({
}) })
/**
* Returns whether the caller is signed in and whether their email is on the admin allowlist. Always 200; never requires auth.
* @summary Current authentication and admin status
*/
export const GetMeResponse = zod.object({
"authenticated": zod.boolean(),
"isAdmin": zod.boolean(),
"email": zod.string().nullish().describe('The signed-in user\'s primary email, when available')
})
/** /**
* Aggregated statistics across all scans. * Aggregated statistics across all scans.
* @summary Dashboard summary * @summary Dashboard summary
@ -65,6 +76,7 @@ export const GetDashboardResponse = zod.object({
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'), "relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'),
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'), "similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'),
"comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'), "comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'),
"hidden": zod.boolean().describe('Whether an admin has hidden this scan from the public catalog'),
"createdAt": zod.string() "createdAt": zod.string()
})), })),
"topRules": zod.array(zod.object({ "topRules": zod.array(zod.object({
@ -104,6 +116,7 @@ export const ListScansResponseItem = zod.object({
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'), "relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'),
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'), "similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'),
"comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'), "comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'),
"hidden": zod.boolean().describe('Whether an admin has hidden this scan from the public catalog'),
"createdAt": zod.string() "createdAt": zod.string()
}) })
export const ListScansResponse = zod.array(ListScansResponseItem) export const ListScansResponse = zod.array(ListScansResponseItem)
@ -224,6 +237,7 @@ export const GetScanResponse = zod.object({
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'), "relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'),
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'), "similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'),
"comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'), "comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'),
"hidden": zod.boolean().describe('Whether an admin has hidden this scan from the public catalog'),
"createdAt": zod.string() "createdAt": zod.string()
}).and(zod.object({ }).and(zod.object({
"files": zod.array(zod.object({ "files": zod.array(zod.object({
@ -270,6 +284,48 @@ export const GetScanResponse = zod.object({
})) }))
/**
* Admin-only. Toggles whether a scan appears in the public catalog.
* @summary Moderate a scan (hide or unhide from the public catalog)
*/
export const ModerateScanParams = zod.object({
"id": zod.coerce.number()
})
export const ModerateScanBody = zod.object({
"hidden": zod.boolean().describe('Whether to hide the scan from the public catalog')
})
export const ModerateScanResponse = zod.object({
"id": zod.number(),
"name": zod.string(),
"description": zod.string().nullish().describe('AI-generated summary of the skill\'s purpose (null when no AI description is available)'),
"source": zod.enum(['zip', 'file', 'text']),
"status": zod.enum(['completed', 'failed']),
"verdict": zod.enum(['pass', 'review', 'block']),
"riskScore": zod.number(),
"fileCount": zod.number(),
"aiUsed": zod.boolean(),
"aiError": zod.string().nullish(),
"findingCounts": zod.object({
"critical": zod.number(),
"high": zod.number(),
"medium": zod.number(),
"low": zod.number(),
"info": zod.number(),
"security": zod.number(),
"privacy": zod.number(),
"total": zod.number()
}),
"fingerprint": zod.string().describe('Deterministic hash over all files (path + per-file hash)'),
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'),
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'),
"comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'),
"hidden": zod.boolean().describe('Whether an admin has hidden this scan from the public catalog'),
"createdAt": zod.string()
})
/** /**
* @summary Delete a scan report * @summary Delete a scan report
*/ */
@ -310,6 +366,7 @@ export const GenerateScanDescriptionResponse = zod.object({
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'), "relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'),
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'), "similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'),
"comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'), "comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'),
"hidden": zod.boolean().describe('Whether an admin has hidden this scan from the public catalog'),
"createdAt": zod.string() "createdAt": zod.string()
}).and(zod.object({ }).and(zod.object({
"files": zod.array(zod.object({ "files": zod.array(zod.object({

View file

@ -0,0 +1,17 @@
/**
* Generated by orval v8.9.1 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface AuthMe {
authenticated: boolean;
isAdmin: boolean;
/**
* The signed-in user's primary email, when available
* @nullable
*/
email?: string | null;
}

View file

@ -13,6 +13,7 @@ export * from './aiProviderInputApiType';
export * from './aiProviderUpdate'; export * from './aiProviderUpdate';
export * from './aiProviderUpdateApiType'; export * from './aiProviderUpdateApiType';
export * from './apiError'; export * from './apiError';
export * from './authMe';
export * from './axisTotals'; export * from './axisTotals';
export * from './comparedScan'; export * from './comparedScan';
export * from './comparedScanVerdict'; export * from './comparedScanVerdict';
@ -58,6 +59,7 @@ export * from './scanFileKind';
export * from './scanLineageEntry'; export * from './scanLineageEntry';
export * from './scanLineageEntryRelation'; export * from './scanLineageEntryRelation';
export * from './scanLineageEntryVerdict'; export * from './scanLineageEntryVerdict';
export * from './scanModerationUpdate';
export * from './scanRelation'; export * from './scanRelation';
export * from './scanSource'; export * from './scanSource';
export * from './scanStatus'; export * from './scanStatus';

View file

@ -45,5 +45,7 @@ export interface Scan {
* @nullable * @nullable
*/ */
comparedScanId: number | null; comparedScanId: number | null;
/** Whether an admin has hidden this scan from the public catalog */
hidden: boolean;
createdAt: string; createdAt: string;
} }

View file

@ -0,0 +1,12 @@
/**
* Generated by orval v8.9.1 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface ScanModerationUpdate {
/** Whether to hide the scan from the public catalog */
hidden: boolean;
}

View file

@ -59,6 +59,7 @@ export const scansTable = pgTable(
relation: text("relation").$type<ScanRelation>(), relation: text("relation").$type<ScanRelation>(),
similarity: integer("similarity"), similarity: integer("similarity"),
comparedScanId: integer("compared_scan_id"), comparedScanId: integer("compared_scan_id"),
hidden: boolean("hidden").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),

212
pnpm-lock.yaml generated
View file

@ -166,6 +166,12 @@ importers:
artifacts/api-server: artifacts/api-server:
dependencies: dependencies:
'@clerk/express':
specifier: ^2.1.23
version: 2.1.23(express@5.2.1)
'@clerk/shared':
specifier: ^4.15.0
version: 4.15.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@workspace/api-zod': '@workspace/api-zod':
specifier: workspace:* specifier: workspace:*
version: link:../../lib/api-zod version: link:../../lib/api-zod
@ -184,9 +190,15 @@ importers:
express: express:
specifier: ^5.2.1 specifier: ^5.2.1
version: 5.2.1 version: 5.2.1
express-rate-limit:
specifier: ^8.5.2
version: 8.5.2(express@5.2.1)
fflate: fflate:
specifier: ^0.8.3 specifier: ^0.8.3
version: 0.8.3 version: 0.8.3
http-proxy-middleware:
specifier: ^4.1.0
version: 4.1.0
pino: pino:
specifier: ^9.14.0 specifier: ^9.14.0
version: 9.14.0 version: 9.14.0
@ -406,6 +418,13 @@ importers:
version: 3.25.76 version: 3.25.76
artifacts/skillguard: artifacts/skillguard:
dependencies:
'@clerk/react':
specifier: ^6.7.3
version: 6.7.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@clerk/themes':
specifier: ^2.4.57
version: 2.4.57(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
devDependencies: devDependencies:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^3.10.0 specifier: ^3.10.0
@ -738,6 +757,51 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@clerk/backend@3.5.0':
resolution: {integrity: sha512-g1bjzqj7/mb6rnrQ5zr+ybjpilTIWADaCoHE1HnEAjQfbcdfn3gb9PPX0wJ1KWw8OOQEQYSjzXPzvofEB1j+ww==}
engines: {node: '>=20.9.0'}
'@clerk/express@2.1.23':
resolution: {integrity: sha512-Z+KK5im5yf33nWpEbzEHbWJDIROqTKl/ZgbTPl+zDmD+WZ0kWH+nqXTtq6wb6pN+k1cu4RRoRJLc6aZ3AYQuIA==}
engines: {node: '>=20.9.0'}
peerDependencies:
express: ^4.17.0 || ^5.0.0
'@clerk/react@6.7.3':
resolution: {integrity: sha512-xdml8bFXbOQ/Egyp7iI1f0ksLjw5nYu2Db+mttHpJzet7PRXQ3jBEEc2c0AYhOJvIYxJifHGBsf75NM8SwlOag==}
engines: {node: '>=20.9.0'}
peerDependencies:
react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
'@clerk/shared@3.47.7':
resolution: {integrity: sha512-9Yv4MJFEaC7BzV0whxa4txQ4SoMu/3j1LBnI85EBykb5CcfXxIKvNX/9sjMUUySHlTOjsj7XZa5i3W5Dx02K/Q==}
engines: {node: '>=18.17.0'}
peerDependencies:
react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
'@clerk/shared@4.15.0':
resolution: {integrity: sha512-uX8nfLb69m8mA6KWKWfuPSwoVNDRyUdufeCeTEZsdZxbRUsEYT/c0KWFN28IOQCtK09tpVtzrUHvW44v5Dc5OA==}
engines: {node: '>=20.9.0'}
peerDependencies:
react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
'@clerk/themes@2.4.57':
resolution: {integrity: sha512-Nb3bO79rMTU/MPVTC/dde6LG27/IgOMKIYi5KSvAmO4ZUHlj0OWufu6CMvz5OYVZ0YdyMnTBU2aPGRUiRzO+2w==}
engines: {node: '>=18.17.0'}
'@commander-js/extra-typings@14.0.0': '@commander-js/extra-typings@14.0.0':
resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==} resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==}
peerDependencies: peerDependencies:
@ -1571,6 +1635,9 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@ -1921,6 +1988,9 @@ packages:
engines: {node: '>=4'} engines: {node: '>=4'}
hasBin: true hasBin: true
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@ -1996,6 +2066,10 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2212,6 +2286,12 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
express-rate-limit@8.5.2:
resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
express@5.2.1: express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@ -2233,6 +2313,9 @@ packages:
fast-safe-stringify@2.1.1: fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fast-uri@3.1.2: fast-uri@3.1.2:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
@ -2332,6 +2415,9 @@ packages:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
glob-to-regexp@0.4.1:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
globby@16.1.0: globby@16.1.0:
resolution: {integrity: sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ==} resolution: {integrity: sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@ -2358,6 +2444,13 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
http-proxy-middleware@4.1.0:
resolution: {integrity: sha512-XUOAjKncPZjsrgDTTem+CUED6A+piUAKTOwBM8g1gAublBX/GdxTHP8mO+og1EW1evYP8wd0G2c111tExwo/jw==}
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0}
httpxy@0.5.3:
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
human-signals@8.0.1: human-signals@8.0.1:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@ -2383,6 +2476,10 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'} engines: {node: '>=12'}
ip-address@10.2.0:
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -2429,6 +2526,10 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'} engines: {node: '>=10'}
js-cookie@3.0.7:
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
engines: {node: '>=20'}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -2994,10 +3095,16 @@ packages:
stackback@0.0.2: stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
statuses@2.0.2: statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
std-env@4.1.0: std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
@ -3017,6 +3124,11 @@ packages:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
swr@2.3.4:
resolution: {integrity: sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==}
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
tailwind-merge@3.5.0: tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
@ -3416,6 +3528,62 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@clerk/backend@3.5.0':
dependencies:
'@clerk/shared': 4.15.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
standardwebhooks: 1.0.0
tslib: 2.8.1
transitivePeerDependencies:
- react
- react-dom
'@clerk/express@2.1.23(express@5.2.1)':
dependencies:
'@clerk/backend': 3.5.0
'@clerk/shared': 4.15.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
express: 5.2.1
tslib: 2.8.1
transitivePeerDependencies:
- react
- react-dom
'@clerk/react@6.7.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@clerk/shared': 4.15.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
tslib: 2.8.1
'@clerk/shared@3.47.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
csstype: 3.1.3
dequal: 2.0.3
glob-to-regexp: 0.4.1
js-cookie: 3.0.7
std-env: 3.10.0
swr: 2.3.4(react@19.1.0)
optionalDependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
'@clerk/shared@4.15.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@tanstack/query-core': 5.100.9
dequal: 2.0.3
glob-to-regexp: 0.4.1
js-cookie: 3.0.7
optionalDependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
'@clerk/themes@2.4.57(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@clerk/shared': 3.47.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
tslib: 2.8.1
transitivePeerDependencies:
- react
- react-dom
'@commander-js/extra-typings@14.0.0(commander@14.0.3)': '@commander-js/extra-typings@14.0.0(commander@14.0.3)':
dependencies: dependencies:
commander: 14.0.3 commander: 14.0.3
@ -4373,6 +4541,8 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {} '@sindresorhus/merge-streams@4.0.0': {}
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@tabby_ai/hijri-converter@1.0.5': {} '@tabby_ai/hijri-converter@1.0.5': {}
@ -4741,6 +4911,8 @@ snapshots:
cssesc@3.0.0: {} cssesc@3.0.0: {}
csstype@3.1.3: {}
csstype@3.2.3: {} csstype@3.2.3: {}
d3-array@3.2.4: d3-array@3.2.4:
@ -4797,6 +4969,8 @@ snapshots:
depd@2.0.0: {} depd@2.0.0: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
detect-node-es@1.1.0: {} detect-node-es@1.1.0: {}
@ -4916,6 +5090,11 @@ snapshots:
expect-type@1.3.0: {} expect-type@1.3.0: {}
express-rate-limit@8.5.2(express@5.2.1):
dependencies:
express: 5.2.1
ip-address: 10.2.0
express@5.2.1: express@5.2.1:
dependencies: dependencies:
accepts: 2.0.0 accepts: 2.0.0
@ -4965,6 +5144,8 @@ snapshots:
fast-safe-stringify@2.1.1: {} fast-safe-stringify@2.1.1: {}
fast-sha256@1.3.0: {}
fast-uri@3.1.2: {} fast-uri@3.1.2: {}
fastq@1.20.1: fastq@1.20.1:
@ -5062,6 +5243,8 @@ snapshots:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
glob-to-regexp@0.4.1: {}
globby@16.1.0: globby@16.1.0:
dependencies: dependencies:
'@sindresorhus/merge-streams': 4.0.0 '@sindresorhus/merge-streams': 4.0.0
@ -5091,6 +5274,18 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 toidentifier: 1.0.1
http-proxy-middleware@4.1.0:
dependencies:
debug: 4.4.3
httpxy: 0.5.3
is-glob: 4.0.3
is-plain-obj: 4.1.0
micromatch: 4.0.8
transitivePeerDependencies:
- supports-color
httpxy@0.5.3: {}
human-signals@8.0.1: {} human-signals@8.0.1: {}
iconv-lite@0.7.2: iconv-lite@0.7.2:
@ -5108,6 +5303,8 @@ snapshots:
internmap@2.0.3: {} internmap@2.0.3: {}
ip-address@10.2.0: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
is-extglob@2.1.1: {} is-extglob@2.1.1: {}
@ -5134,6 +5331,8 @@ snapshots:
joycon@3.1.1: {} joycon@3.1.1: {}
js-cookie@3.0.7: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.1.1: js-yaml@4.1.1:
@ -5707,8 +5906,15 @@ snapshots:
stackback@0.0.2: {} stackback@0.0.2: {}
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
statuses@2.0.2: {} statuses@2.0.2: {}
std-env@3.10.0: {}
std-env@4.1.0: {} std-env@4.1.0: {}
string-argv@0.3.2: {} string-argv@0.3.2: {}
@ -5721,6 +5927,12 @@ snapshots:
strip-json-comments@5.0.3: {} strip-json-comments@5.0.3: {}
swr@2.3.4(react@19.1.0):
dependencies:
dequal: 2.0.3
react: 19.1.0
use-sync-external-store: 1.6.0(react@19.1.0)
tailwind-merge@3.5.0: {} tailwind-merge@3.5.0: {}
tailwindcss-animate@1.0.7(tailwindcss@4.3.0): tailwindcss-animate@1.0.7(tailwindcss@4.3.0):