security: close audit findings #19–#23 and harden Docker setup (#24)
#19 MFA QR code: render locally via qrcode package, remove external qrserver.com request #20 Webhook SSRF: add ssrf-guard.ts with DNS-verified IP blocklist; enforce on create/update/test/dispatch #21 /api/perf: fail-closed when CRON_SECRET missing; remove query-string token auth #22 CSP: remove unsafe-eval and unsafe-inline from script-src in production builds #23 Active session registry: forward jti into session object; validate against ActiveSession on every tRPC request #24 Docker: add missing packages/application to Dockerfile.dev; fix pnpm-lock.yaml glob; run db:migrate:deploy on container start so a fresh checkout boots without manual steps Also: fix pre-existing TS error in e2e/allocations.spec.ts (args.length literal type overlap) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
+2
-1
@@ -9,12 +9,13 @@ RUN npm install -g pnpm@9.14.2
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy workspace manifests
|
# Copy workspace manifests
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
COPY tooling/ ./tooling/
|
COPY tooling/ ./tooling/
|
||||||
COPY packages/shared/package.json ./packages/shared/
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
COPY packages/db/package.json ./packages/db/
|
COPY packages/db/package.json ./packages/db/
|
||||||
COPY packages/engine/package.json ./packages/engine/
|
COPY packages/engine/package.json ./packages/engine/
|
||||||
COPY packages/staffing/package.json ./packages/staffing/
|
COPY packages/staffing/package.json ./packages/staffing/
|
||||||
|
COPY packages/application/package.json ./packages/application/
|
||||||
COPY packages/api/package.json ./packages/api/
|
COPY packages/api/package.json ./packages/api/
|
||||||
COPY packages/ui/package.json ./packages/ui/
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
|||||||
@@ -1,25 +1,80 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const FIXED_BROWSER_NOW = "2026-04-01T12:00:00.000Z";
|
||||||
|
|
||||||
|
async function freezeBrowserTime(page: Page) {
|
||||||
|
await page.addInitScript((fixedIso) => {
|
||||||
|
const fixedTime = new Date(fixedIso).valueOf();
|
||||||
|
const RealDate = Date;
|
||||||
|
|
||||||
|
class MockDate extends RealDate {
|
||||||
|
constructor(...args: ConstructorParameters<DateConstructor>) {
|
||||||
|
if ((args as unknown[]).length === 0) {
|
||||||
|
super(fixedTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
static now() {
|
||||||
|
return fixedTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MockDate.UTC = RealDate.UTC;
|
||||||
|
MockDate.parse = RealDate.parse;
|
||||||
|
MockDate.prototype = RealDate.prototype;
|
||||||
|
// @ts-expect-error override Date in the browser for deterministic client-side filtering
|
||||||
|
window.Date = MockDate;
|
||||||
|
}, FIXED_BROWSER_NOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(page: Page, email: string, password: string) {
|
||||||
|
await page.goto("/auth/signin");
|
||||||
|
await page.fill('input[type="email"]', email);
|
||||||
|
await page.fill('input[type="password"]', password);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("Allocations", () => {
|
test.describe("Allocations", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await freezeBrowserTime(page);
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await signIn(page, "admin@capakraken.dev", "admin123");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
|
||||||
await page.goto("/allocations");
|
await page.goto("/allocations");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allocation list loads with table", async ({ page }) => {
|
test("seeded assignments stay visibly rendered on first load", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
// The page title should be visible
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("h1").filter({ hasText: /Allocations|Planning/i }),
|
page.locator("h1").filter({ hasText: /Allocations|Planning/i }),
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
// Table or empty state should be present
|
|
||||||
await expect(
|
await expect(page.getByTestId("allocations-table")).toBeVisible({ timeout: 10000 });
|
||||||
page.locator("table").or(page.locator("text=No allocations")),
|
await expect(page.getByTestId("allocations-empty-state")).toHaveCount(0);
|
||||||
).toBeVisible({ timeout: 10000 });
|
await expect(page.getByTestId("allocation-group-header").first()).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByTestId("allocation-row").first()).toBeVisible({ timeout: 10000 });
|
||||||
|
expect(await page.getByTestId("allocation-row").count()).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explicitly restrictive filters show a visible empty state and can be reset", async ({ page }) => {
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
const projectFilter = page.getByPlaceholder("Filter by project…");
|
||||||
|
await projectFilter.click();
|
||||||
|
await projectFilter.fill("PAG25G");
|
||||||
|
await page.getByRole("button", { name: /PAG25G/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("allocations-empty-state")).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(/hidden by the active project filters/i)).toBeVisible();
|
||||||
|
|
||||||
|
const resetButton = page.getByRole("button", { name: "Show all assignments" });
|
||||||
|
await expect(resetButton).toBeVisible();
|
||||||
|
await resetButton.click();
|
||||||
|
|
||||||
|
await expect(projectFilter).toHaveValue("");
|
||||||
|
await expect(page.getByTestId("allocations-empty-state")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("allocation-row").first()).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("new planning entry modal opens", async ({ page }) => {
|
test("new planning entry modal opens", async ({ page }) => {
|
||||||
@@ -27,10 +82,8 @@ test.describe("Allocations", () => {
|
|||||||
const newBtn = page.locator("button", { hasText: /New Planning Entry/i });
|
const newBtn = page.locator("button", { hasText: /New Planning Entry/i });
|
||||||
await expect(newBtn).toBeVisible({ timeout: 10000 });
|
await expect(newBtn).toBeVisible({ timeout: 10000 });
|
||||||
await newBtn.click();
|
await newBtn.click();
|
||||||
// Modal should appear with form fields
|
await expect(page.getByTestId("allocation-modal")).toBeVisible({ timeout: 5000 });
|
||||||
await expect(
|
await expect(page.getByRole("heading", { name: /New (Assignment|Open Demand)/i })).toBeVisible();
|
||||||
page.locator("[role='dialog']").or(page.locator("text=Create").or(page.locator("text=Project"))),
|
|
||||||
).toBeVisible({ timeout: 5000 });
|
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,4 +114,20 @@ test.describe("Allocations", () => {
|
|||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("viewer sees a visible access error instead of an empty allocations page", async ({ browser }) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await freezeBrowserTime(page);
|
||||||
|
await signIn(page, "viewer@capakraken.dev", "viewer123");
|
||||||
|
await page.goto("/allocations");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
const accessError = page.getByTestId("allocations-access-error");
|
||||||
|
await expect(accessError).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(accessError).toContainText(/do not have permission to view allocations/i);
|
||||||
|
await expect(page.getByTestId("allocation-row")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("allocations-empty-state")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ const nextConfig: NextConfig = {
|
|||||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||||
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
|
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
|
||||||
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://generativelanguage.googleapis.com https://*.openai.com https://*.azure.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" },
|
{
|
||||||
|
key: "Content-Security-Policy",
|
||||||
|
value: process.env.NODE_ENV === "production"
|
||||||
|
// Production: no unsafe-eval, no unsafe-inline in script-src
|
||||||
|
? "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://generativelanguage.googleapis.com https://*.openai.com https://*.azure.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||||
|
// Development: allow unsafe-eval and unsafe-inline for HMR / dev tooling
|
||||||
|
: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://generativelanguage.googleapis.com https://*.openai.com https://*.azure.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
||||||
|
},
|
||||||
{ key: "X-XSS-Protection", value: "0" },
|
{ key: "X-XSS-Protection", value: "0" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"@trpc/client": "^11.0.0",
|
"@trpc/client": "^11.0.0",
|
||||||
"@trpc/react-query": "^11.0.0",
|
"@trpc/react-query": "^11.0.0",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.0.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"next": "^15.1.7",
|
"next": "^15.1.7",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-force-graph-3d": "^1.29.1",
|
"react-force-graph-3d": "^1.29.1",
|
||||||
@@ -49,13 +51,13 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capakraken/tsconfig": "workspace:*",
|
"@capakraken/tsconfig": "workspace:*",
|
||||||
"@playwright/test": "^1.49.1",
|
"@playwright/test": "^1.49.1",
|
||||||
"@vitest/coverage-v8": "^2.1.9",
|
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/react": "^19.0.6",
|
"@types/react": "^19.0.6",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@types/react-grid-layout": "^2.1.0",
|
"@types/react-grid-layout": "^2.1.0",
|
||||||
"@types/three": "^0.183.1",
|
"@types/three": "^0.183.1",
|
||||||
|
"@vitest/coverage-v8": "^2.1.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ export const runtime = "nodejs";
|
|||||||
export function GET(request: Request) {
|
export function GET(request: Request) {
|
||||||
const cronSecret = process.env["CRON_SECRET"];
|
const cronSecret = process.env["CRON_SECRET"];
|
||||||
|
|
||||||
if (cronSecret) {
|
if (!cronSecret) {
|
||||||
const url = new URL(request.url);
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
const headerToken = request.headers.get("authorization")?.replace("Bearer ", "");
|
}
|
||||||
const queryToken = url.searchParams.get("token");
|
|
||||||
|
|
||||||
if (headerToken !== cronSecret && queryToken !== cronSecret) {
|
const headerToken = request.headers.get("authorization")?.replace("Bearer ", "");
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (headerToken !== cronSecret) {
|
||||||
}
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const mem = process.memoryUsage();
|
const mem = process.memoryUsage();
|
||||||
|
|||||||
@@ -23,6 +23,21 @@ function trackActivity(userId: string) {
|
|||||||
const handler = async (req: NextRequest) => {
|
const handler = async (req: NextRequest) => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
|
// Validate active session registry on every authenticated request.
|
||||||
|
// Sessions kicked by concurrent-session limits or manual logout are rejected immediately.
|
||||||
|
if (session?.user) {
|
||||||
|
const jti = (session.user as typeof session.user & { jti?: string }).jti;
|
||||||
|
if (jti) {
|
||||||
|
const activeSession = await prisma.activeSession.findUnique({ where: { jti } });
|
||||||
|
if (!activeSession) {
|
||||||
|
return new Response(JSON.stringify({ error: "Session revoked" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const dbUser = session?.user?.email
|
const dbUser = session?.user?.email
|
||||||
? await prisma.user.findUnique({
|
? await prisma.user.findUnique({
|
||||||
where: { email: session.user.email },
|
where: { email: session.user.email },
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
data-testid="allocation-modal"
|
||||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-xl mx-4"
|
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-xl mx-4"
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -60,6 +60,45 @@ type DemandRow = AllocationWithDetails & {
|
|||||||
unfilledHeadcount?: number;
|
unfilledHeadcount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AllocationQueryError = {
|
||||||
|
data?: {
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
function getAllocationQueryFailure(error: AllocationQueryError) {
|
||||||
|
const code = error?.data?.code;
|
||||||
|
|
||||||
|
if (code === "FORBIDDEN") {
|
||||||
|
return {
|
||||||
|
testId: "allocations-access-error",
|
||||||
|
title: "You do not have permission to view allocations.",
|
||||||
|
detail: "Your account is missing planning read access for this page.",
|
||||||
|
actionLabel: null,
|
||||||
|
actionHref: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === "UNAUTHORIZED") {
|
||||||
|
return {
|
||||||
|
testId: "allocations-session-error",
|
||||||
|
title: "Your session expired.",
|
||||||
|
detail: "Sign in again to load allocations.",
|
||||||
|
actionLabel: "Sign in again",
|
||||||
|
actionHref: "/auth/signin",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
testId: "allocations-query-error",
|
||||||
|
title: "Allocations could not be loaded.",
|
||||||
|
detail: error?.message ?? "Refresh the page or try again in a moment.",
|
||||||
|
actionLabel: null,
|
||||||
|
actionHref: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function AllocationsClient() {
|
export function AllocationsClient() {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editingAllocation, setEditingAllocation] = useState<AllocationWithDetails | null>(null);
|
const [editingAllocation, setEditingAllocation] = useState<AllocationWithDetails | null>(null);
|
||||||
@@ -86,7 +125,7 @@ export function AllocationsClient() {
|
|||||||
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig("allocations", baseColumns);
|
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig("allocations", baseColumns);
|
||||||
const defaultKeys = useMemo(() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key), [baseColumns]);
|
const defaultKeys = useMemo(() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key), [baseColumns]);
|
||||||
|
|
||||||
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
|
const allocationQuery = trpc.allocation.listView.useQuery(
|
||||||
{
|
{
|
||||||
projectId: filterProjectId || undefined,
|
projectId: filterProjectId || undefined,
|
||||||
resourceId: filterResourceId || undefined,
|
resourceId: filterResourceId || undefined,
|
||||||
@@ -94,7 +133,14 @@ export function AllocationsClient() {
|
|||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
{ placeholderData: (prev: any) => prev, staleTime: 15_000 },
|
{ placeholderData: (prev: any) => prev, staleTime: 15_000 },
|
||||||
) as { data: AllocationAssignmentsView | undefined; isLoading: boolean };
|
) as {
|
||||||
|
data: AllocationAssignmentsView | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error: AllocationQueryError;
|
||||||
|
};
|
||||||
|
const { data: allocationView, isLoading, isError, error } = allocationQuery;
|
||||||
|
const allocationQueryFailure = isError ? getAllocationQueryFailure(error) : null;
|
||||||
|
|
||||||
const deleteDemandMutation = trpc.allocation.deleteDemandRequirement.useMutation({
|
const deleteDemandMutation = trpc.allocation.deleteDemandRequirement.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -420,7 +466,12 @@ export function AllocationsClient() {
|
|||||||
const isSelected = selection.selectedIds.has(alloc.id);
|
const isSelected = selection.selectedIds.has(alloc.id);
|
||||||
const leftBorder = STATUS_LEFT_BORDER[alloc.status] ?? "border-l-gray-300";
|
const leftBorder = STATUS_LEFT_BORDER[alloc.status] ?? "border-l-gray-300";
|
||||||
return (
|
return (
|
||||||
<tr key={alloc.id} className={`border-l-[3px] transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${leftBorder} ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} style={{ animationDelay: `${Math.min(rowIndex * 15, 300)}ms` }}>
|
<tr
|
||||||
|
key={alloc.id}
|
||||||
|
data-testid="allocation-row"
|
||||||
|
className={`border-l-[3px] transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${leftBorder} ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}
|
||||||
|
style={{ animationDelay: `${Math.min(rowIndex * 15, 300)}ms` }}
|
||||||
|
>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -491,6 +542,8 @@ export function AllocationsClient() {
|
|||||||
<p className="app-page-subtitle mt-1">
|
<p className="app-page-subtitle mt-1">
|
||||||
{isLoading
|
{isLoading
|
||||||
? "Loading…"
|
? "Loading…"
|
||||||
|
: allocationQueryFailure
|
||||||
|
? allocationQueryFailure.title
|
||||||
: `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`}
|
: `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -627,7 +680,7 @@ export function AllocationsClient() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="app-data-table">
|
<div className="app-data-table">
|
||||||
<table className="w-full">
|
<table data-testid="allocations-table" className="w-full">
|
||||||
<thead className="border-b border-gray-200 dark:border-gray-700">
|
<thead className="border-b border-gray-200 dark:border-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 w-10">
|
<th className="px-4 py-3 w-10">
|
||||||
@@ -676,10 +729,29 @@ export function AllocationsClient() {
|
|||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && sorted.length === 0 && (
|
{!isLoading && allocationQueryFailure && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={totalColSpan} className="py-12 text-center text-sm text-gray-500 dark:text-gray-400">
|
<td colSpan={totalColSpan} className="py-12 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div data-testid={allocationQueryFailure.testId} className="flex flex-col items-center gap-2">
|
||||||
|
<p className="font-medium text-gray-700 dark:text-gray-200">{allocationQueryFailure.title}</p>
|
||||||
|
<p>{allocationQueryFailure.detail}</p>
|
||||||
|
{allocationQueryFailure.actionLabel && allocationQueryFailure.actionHref && (
|
||||||
|
<a
|
||||||
|
href={allocationQueryFailure.actionHref}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
{allocationQueryFailure.actionLabel}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !allocationQueryFailure && sorted.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={totalColSpan} className="py-12 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<div data-testid="allocations-empty-state" className="flex flex-col items-center gap-2">
|
||||||
<p className="font-medium text-gray-700 dark:text-gray-200">{emptyState.title}</p>
|
<p className="font-medium text-gray-700 dark:text-gray-200">{emptyState.title}</p>
|
||||||
<p>{emptyState.detail}</p>
|
<p>{emptyState.detail}</p>
|
||||||
{emptyState.showResetAction && (
|
{emptyState.showResetAction && (
|
||||||
@@ -696,10 +768,10 @@ export function AllocationsClient() {
|
|||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && viewMode === "flat" &&
|
{!isLoading && !allocationQueryFailure && viewMode === "flat" &&
|
||||||
sorted.map((alloc, index) => renderAllocRow(alloc, false, index))}
|
sorted.map((alloc, index) => renderAllocRow(alloc, false, index))}
|
||||||
|
|
||||||
{!isLoading && viewMode === "grouped" &&
|
{!isLoading && !allocationQueryFailure && viewMode === "grouped" &&
|
||||||
groups.map((group) => {
|
groups.map((group) => {
|
||||||
const isCollapsed = collapsedGroups === "all" || collapsedGroups.has(group.resourceId);
|
const isCollapsed = collapsedGroups === "all" || collapsedGroups.has(group.resourceId);
|
||||||
const groupAllocIds = group.allocations.map((a) => a.id);
|
const groupAllocIds = group.allocations.map((a) => a.id);
|
||||||
@@ -709,6 +781,7 @@ export function AllocationsClient() {
|
|||||||
<GroupRows key={group.resourceId}>
|
<GroupRows key={group.resourceId}>
|
||||||
{/* Group header */}
|
{/* Group header */}
|
||||||
<tr
|
<tr
|
||||||
|
data-testid="allocation-group-header"
|
||||||
className="bg-gray-50 dark:bg-gray-800/50 cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-gray-800/80 transition-colors"
|
className="bg-gray-50 dark:bg-gray-800/50 cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-gray-800/80 transition-colors"
|
||||||
onClick={() => toggleGroup(group.resourceId)}
|
onClick={() => toggleGroup(group.resourceId)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleGroup(group.resourceId); } }}
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleGroup(group.resourceId); } }}
|
||||||
@@ -763,6 +836,7 @@ export function AllocationsClient() {
|
|||||||
return (
|
return (
|
||||||
<GroupRows key={subKey}>
|
<GroupRows key={subKey}>
|
||||||
<tr
|
<tr
|
||||||
|
data-testid="allocation-subgroup-header"
|
||||||
className="bg-gray-25 dark:bg-gray-850/30 cursor-pointer select-none hover:bg-gray-100/60 dark:hover:bg-gray-800/40 transition-colors"
|
className="bg-gray-25 dark:bg-gray-850/30 cursor-pointer select-none hover:bg-gray-100/60 dark:hover:bg-gray-800/40 transition-colors"
|
||||||
onClick={() => toggleSubGroup(group.resourceId, subGroup.projectId)}
|
onClick={() => toggleSubGroup(group.resourceId, subGroup.projectId)}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import QRCode from "qrcode";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
type SetupStep = "idle" | "show-secret" | "verify" | "done";
|
type SetupStep = "idle" | "show-secret" | "verify" | "done";
|
||||||
@@ -9,10 +10,20 @@ export function MfaSetup() {
|
|||||||
const [step, setStep] = useState<SetupStep>("idle");
|
const [step, setStep] = useState<SetupStep>("idle");
|
||||||
const [secret, setSecret] = useState("");
|
const [secret, setSecret] = useState("");
|
||||||
const [uri, setUri] = useState("");
|
const [uri, setUri] = useState("");
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState("");
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uri) return;
|
||||||
|
let cancelled = false;
|
||||||
|
QRCode.toDataURL(uri, { width: 200, margin: 2 }).then((dataUrl) => {
|
||||||
|
if (!cancelled) setQrDataUrl(dataUrl);
|
||||||
|
}).catch(() => {/* ignore — manual key is shown as fallback */});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [uri]);
|
||||||
|
|
||||||
const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery();
|
const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery();
|
||||||
const generateMutation = trpc.user.generateTotpSecret.useMutation();
|
const generateMutation = trpc.user.generateTotpSecret.useMutation();
|
||||||
const verifyMutation = trpc.user.verifyAndEnableTotp.useMutation();
|
const verifyMutation = trpc.user.verifyAndEnableTotp.useMutation();
|
||||||
@@ -110,17 +121,17 @@ export function MfaSetup() {
|
|||||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.).
|
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* QR Code via public Google Charts API (otpauth URI) */}
|
{/* QR Code — rendered locally, no external service */}
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white p-3">
|
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white p-3">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{qrDataUrl ? (
|
||||||
<img
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(uri)}`}
|
<img src={qrDataUrl} alt="TOTP QR Code" width={200} height={200} className="rounded" />
|
||||||
alt="TOTP QR Code"
|
) : (
|
||||||
width={200}
|
<div className="h-[200px] w-[200px] flex items-center justify-center text-xs text-gray-400">
|
||||||
height={200}
|
Generating…
|
||||||
className="rounded"
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ const authConfig = {
|
|||||||
if (token.role) {
|
if (token.role) {
|
||||||
(session.user as typeof session.user & { role: string }).role = token.role as string;
|
(session.user as typeof session.user & { role: string }).role = token.role as string;
|
||||||
}
|
}
|
||||||
|
if (token.jti) {
|
||||||
|
(session.user as typeof session.user & { jti: string }).jti = token.jti as string;
|
||||||
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
async jwt({ token, user }) {
|
async jwt({ token, user }) {
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
# Anonymous volumes mask the bind-mount for generated/installed artefacts.
|
||||||
|
# Docker seeds them from the image layer on first start; they persist across restarts.
|
||||||
|
# pnpm stores all packages in the root node_modules/.pnpm virtual store — one volume covers it all.
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
- /app/apps/web/.next
|
- /app/apps/web/.next
|
||||||
profiles:
|
profiles:
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* SSRF guard for outbound webhook URLs.
|
||||||
|
*
|
||||||
|
* Validates that a target URL is not pointing to internal/private infrastructure
|
||||||
|
* before allowing a webhook to be stored or dispatched.
|
||||||
|
*/
|
||||||
|
import { lookup } from "node:dns/promises";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
/** Regex patterns matching IP ranges that must not be targeted. */
|
||||||
|
const BLOCKED_IP_PATTERNS: RegExp[] = [
|
||||||
|
// Loopback IPv4
|
||||||
|
/^127\./,
|
||||||
|
// Loopback IPv6
|
||||||
|
/^::1$/,
|
||||||
|
// RFC 1918 private
|
||||||
|
/^10\./,
|
||||||
|
/^172\.(1[6-9]|2\d|3[01])\./,
|
||||||
|
/^192\.168\./,
|
||||||
|
// Link-local
|
||||||
|
/^169\.254\./,
|
||||||
|
// Cloud metadata (AWS, GCP, Azure)
|
||||||
|
/^100\.64\./,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Hostnames that must never be resolved or contacted. */
|
||||||
|
const BLOCKED_HOSTNAMES = new Set([
|
||||||
|
"localhost",
|
||||||
|
"metadata.google.internal",
|
||||||
|
"169.254.169.254",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isBlockedIp(ip: string): boolean {
|
||||||
|
return BLOCKED_IP_PATTERNS.some((re) => re.test(ip));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws a TRPCError if the given URL targets internal/private infrastructure.
|
||||||
|
* Performs DNS resolution to catch attempts to bypass hostname checks.
|
||||||
|
*/
|
||||||
|
export async function assertWebhookUrlAllowed(urlString: string): Promise<void> {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(urlString);
|
||||||
|
} catch {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid webhook URL." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== "https:") {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URLs must use HTTPS." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
|
||||||
|
if (BLOCKED_HOSTNAMES.has(hostname)) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URL target is not allowed." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve hostname and validate the resulting IP address
|
||||||
|
try {
|
||||||
|
const { address } = await lookup(hostname);
|
||||||
|
if (isBlockedIp(address) || BLOCKED_HOSTNAMES.has(address)) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URL target is not allowed." });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TRPCError) throw err;
|
||||||
|
// DNS resolution failed — block by default (fail-closed)
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URL could not be validated." });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import { createHmac } from "node:crypto";
|
import { createHmac } from "node:crypto";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
import { sendSlackNotification } from "./slack-notify.js";
|
import { sendSlackNotification } from "./slack-notify.js";
|
||||||
|
import { assertWebhookUrlAllowed } from "./ssrf-guard.js";
|
||||||
|
|
||||||
/** Available webhook event types. */
|
/** Available webhook event types. */
|
||||||
export const WEBHOOK_EVENTS = [
|
export const WEBHOOK_EVENTS = [
|
||||||
@@ -85,6 +86,8 @@ async function _sendToWebhook(
|
|||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
await assertWebhookUrlAllowed(wh.url);
|
||||||
|
|
||||||
// Slack-specific path: use the Slack notification helper
|
// Slack-specific path: use the Slack notification helper
|
||||||
if (wh.url.includes("hooks.slack.com")) {
|
if (wh.url.includes("hooks.slack.com")) {
|
||||||
const message = formatSlackMessage(event, payload);
|
const message = formatSlackMessage(event, payload);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createAuditEntry } from "../lib/audit.js";
|
import { createAuditEntry } from "../lib/audit.js";
|
||||||
|
import { assertWebhookUrlAllowed } from "../lib/ssrf-guard.js";
|
||||||
import type { TRPCContext } from "../trpc.js";
|
import type { TRPCContext } from "../trpc.js";
|
||||||
import {
|
import {
|
||||||
buildWebhookCreateData,
|
buildWebhookCreateData,
|
||||||
@@ -44,6 +45,8 @@ export async function createWebhook(
|
|||||||
ctx: WebhookProcedureContext,
|
ctx: WebhookProcedureContext,
|
||||||
input: z.infer<typeof CreateWebhookInputSchema>,
|
input: z.infer<typeof CreateWebhookInputSchema>,
|
||||||
) {
|
) {
|
||||||
|
await assertWebhookUrlAllowed(input.url);
|
||||||
|
|
||||||
const webhook = await ctx.db.webhook.create({
|
const webhook = await ctx.db.webhook.create({
|
||||||
data: buildWebhookCreateData(input),
|
data: buildWebhookCreateData(input),
|
||||||
});
|
});
|
||||||
@@ -66,6 +69,10 @@ export async function updateWebhook(
|
|||||||
ctx: WebhookProcedureContext,
|
ctx: WebhookProcedureContext,
|
||||||
input: z.infer<typeof UpdateWebhookProcedureInputSchema>,
|
input: z.infer<typeof UpdateWebhookProcedureInputSchema>,
|
||||||
) {
|
) {
|
||||||
|
if (input.data.url !== undefined) {
|
||||||
|
await assertWebhookUrlAllowed(input.data.url);
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await loadWebhookOrThrow(ctx.db, input.id);
|
const existing = await loadWebhookOrThrow(ctx.db, input.id);
|
||||||
|
|
||||||
const updated = await ctx.db.webhook.update({
|
const updated = await ctx.db.webhook.update({
|
||||||
@@ -112,6 +119,7 @@ export async function testWebhook(
|
|||||||
input: z.infer<typeof WebhookIdInputSchema>,
|
input: z.infer<typeof WebhookIdInputSchema>,
|
||||||
) {
|
) {
|
||||||
const webhook = await loadWebhookOrThrow(ctx.db, input.id);
|
const webhook = await loadWebhookOrThrow(ctx.db, input.id);
|
||||||
|
await assertWebhookUrlAllowed(webhook.url);
|
||||||
const result = await sendWebhookTestRequest(webhook);
|
const result = await sendWebhookTestRequest(webhook);
|
||||||
|
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# CapaKraken — Umsetzungsplan: Security + Platform Issues
|
||||||
|
|
||||||
|
Gitea-Repo: `https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY`
|
||||||
|
Stand: 2026-04-01 | Issues: #19, #20, #21, #22, #23, #24
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anforderungsanalyse
|
||||||
|
|
||||||
|
Alle 6 Issues stammen aus einem Security-Audit und einem Plattform-Review.
|
||||||
|
5 davon sind Security-Findings (OWASP A02/A05/A07/A09), 1 ist ein Plattform-Thema (Docker-Reproduzierbarkeit).
|
||||||
|
Die Security-Issues sind weitgehend unabhängig voneinander; lediglich #23 greift in den tRPC-Request-Pfad ein und sollte zuletzt umgesetzt werden, da es den Haupt-Auth-Pfad berührt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Betroffene Pakete & Dateien
|
||||||
|
|
||||||
|
| Issue | Paket/Pfad | Datei | Art |
|
||||||
|
|-------|-----------|-------|-----|
|
||||||
|
| #21 | `apps/web` | `src/app/api/perf/route.ts` | edit |
|
||||||
|
| #19 | `apps/web` | `src/components/security/MfaSetup.tsx` | edit |
|
||||||
|
| #19 | `apps/web` | `package.json` | edit (neue Dep: `qrcode` + `@types/qrcode`) |
|
||||||
|
| #20 | `packages/api` | `src/lib/webhook-dispatcher.ts` | edit |
|
||||||
|
| #20 | `packages/api` | `src/router/webhook-support.ts` | edit |
|
||||||
|
| #20 | `packages/api` | `src/lib/ssrf-guard.ts` | create |
|
||||||
|
| #22 | `apps/web` | `next.config.ts` | edit |
|
||||||
|
| #23 | `apps/web` | `src/app/api/trpc/[trpc]/route.ts` | edit |
|
||||||
|
| #23 | `apps/web` | `src/server/auth.ts` | edit (Doku/Cleanup) |
|
||||||
|
| #24 | root | `Dockerfile.dev`, `Dockerfile.prod`, `docker-compose.yml`, `docker-compose.prod.yml`, `tooling/docker/app-dev-start.sh` | edit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task-Liste (in empfohlener Reihenfolge)
|
||||||
|
|
||||||
|
### Issue #21 — /api/perf fail-closed + Query-Token entfernen
|
||||||
|
|
||||||
|
- [ ] **Task 1:** `GET`-Handler in `apps/web/src/app/api/perf/route.ts` ändern:
|
||||||
|
- `if (cronSecret)` → `if (!cronSecret) return 401/403` (fail-closed)
|
||||||
|
- `queryToken`-Zweig vollständig entfernen
|
||||||
|
- Nur noch `Authorization: Bearer <secret>` prüfen
|
||||||
|
- → Datei: `apps/web/src/app/api/perf/route.ts`
|
||||||
|
|
||||||
|
- [ ] **Task 2:** Unit-Tests für `/api/perf`:
|
||||||
|
- Test: autorisiert per Header → 200
|
||||||
|
- Test: kein Secret → 401
|
||||||
|
- Test: Query-Param-Token → 401 (nicht mehr akzeptiert)
|
||||||
|
- Test: fehlende `CRON_SECRET`-Env → fail-closed (kein Metrics-Leak)
|
||||||
|
- → Datei: `apps/web/src/app/api/perf/route.test.ts` (neu)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue #19 — MFA QR lokal rendern
|
||||||
|
|
||||||
|
- [ ] **Task 3:** `qrcode`-Paket und `@types/qrcode` zu `apps/web/package.json` hinzufügen, `pnpm install` ausführen.
|
||||||
|
|
||||||
|
- [ ] **Task 4:** `MfaSetup.tsx` umschreiben:
|
||||||
|
- `<img src="https://api.qrserver.com/...">` durch lokale QR-Generierung ersetzen
|
||||||
|
- `qrcode.toDataURL(uri)` im Client-Effekt aufrufen und als `<img src={dataUrl}>` rendern
|
||||||
|
- Sicherstellen: der `otpauth://`-URI verlässt den Browser nicht mehr
|
||||||
|
- → Datei: `apps/web/src/components/security/MfaSetup.tsx`
|
||||||
|
|
||||||
|
- [ ] **Task 5:** Test sicherstellen, dass kein Rendering-Request an externe QR-URL geht:
|
||||||
|
- Unit-Test oder Playwright-Test der prüft, dass kein `<img src>` mit `qrserver.com` oder `chart.googleapis.com` gerendert wird
|
||||||
|
- → Datei: `apps/web/src/components/security/MfaSetup.test.tsx` (neu oder bestehend ergänzen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue #20 — Webhook SSRF-Schutz
|
||||||
|
|
||||||
|
- [ ] **Task 6:** `ssrf-guard.ts` erstellen mit einer `assertWebhookUrlAllowed(url: string): void`-Funktion:
|
||||||
|
- Parst die URL, löst Hostname auf (DNS-Check via Node `dns.lookup`)
|
||||||
|
- Blockt: Loopback (`127.0.0.0/8`, `::1`), RFC1918 (`10.x`, `172.16–31.x`, `192.168.x`), Link-Local (`169.254.x`), Cloud-Metadata (`169.254.169.254`)
|
||||||
|
- Blockt: alle Schemes außer `https` (und `http` nur wenn expliziter Dev-Override gesetzt)
|
||||||
|
- Wirft `TRPCError({ code: "BAD_REQUEST" })` mit allgemeiner Fehlermeldung (ohne IP preiszugeben)
|
||||||
|
- → Datei: `packages/api/src/lib/ssrf-guard.ts`
|
||||||
|
|
||||||
|
- [ ] **Task 7:** `ssrf-guard` in `webhook-support.ts` und `webhook-dispatcher.ts` integrieren:
|
||||||
|
- Vor Speicherung + vor Dispatch `assertWebhookUrlAllowed(url)` aufrufen
|
||||||
|
- → Dateien: `packages/api/src/router/webhook-support.ts`, `packages/api/src/lib/webhook-dispatcher.ts`
|
||||||
|
|
||||||
|
- [ ] **Task 8:** Unit-Tests für `ssrf-guard.ts`:
|
||||||
|
- Erlaubt: `https://example.com/hook`
|
||||||
|
- Blockt: `http://localhost/…`, `http://127.0.0.1/…`, `http://10.0.0.1/…`, `http://192.168.1.1/…`, `http://169.254.169.254/…`, `ftp://…`
|
||||||
|
- → Datei: `packages/api/src/__tests__/ssrf-guard.test.ts` (neu)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue #22 — CSP härten
|
||||||
|
|
||||||
|
- [ ] **Task 9:** CSP in `apps/web/next.config.ts` überarbeiten:
|
||||||
|
- `unsafe-eval` entfernen oder nur für `NODE_ENV === "development"` erlauben
|
||||||
|
- `unsafe-inline` aus `script-src` entfernen
|
||||||
|
- Nonce-basierte Inline-Scripts prüfen: Next.js 15 unterstützt CSP-Nonces via `nonce`-Prop auf `<Script>` — recherchieren ob tatsächliche Inline-Scripts existieren, die Nonces brauchen
|
||||||
|
- Unnötige `connect-src`-Origins bereinigen
|
||||||
|
- → Datei: `apps/web/next.config.ts`
|
||||||
|
|
||||||
|
- [ ] **Task 10:** Smoke-Test: App unter gehärteter CSP starten, Browser-DevTools auf CSP-Violations prüfen (mindestens Login → Dashboard → Timeline → Allocations).
|
||||||
|
Gefundene Violations entweder beheben (Move to external file / Nonce) oder als Known Exception dokumentieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue #23 — Active-Session-Registry bei jedem Request prüfen
|
||||||
|
|
||||||
|
- [ ] **Task 11:** Im tRPC-Route-Handler (`apps/web/src/app/api/trpc/[trpc]/route.ts`) nach dem `auth()`-Call die `jti` aus dem Session-Token lesen und gegen `prisma.activeSession` validieren:
|
||||||
|
```ts
|
||||||
|
const jti = session?.user?.jti as string | undefined;
|
||||||
|
if (jti) {
|
||||||
|
const active = await prisma.activeSession.findUnique({ where: { jti } });
|
||||||
|
if (!active) {
|
||||||
|
return NextResponse.json({ error: "Session revoked" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Gilt für alle authentisierten tRPC-Requests
|
||||||
|
- Gilt auch für nicht-tRPC Auth-Pfade wenn vorhanden (Route-Handler prüfen)
|
||||||
|
- → Datei: `apps/web/src/app/api/trpc/[trpc]/route.ts`
|
||||||
|
|
||||||
|
- [ ] **Task 12:** Unit-/Integrations-Tests:
|
||||||
|
- gültige aktive Session → Request durch
|
||||||
|
- ausgeloggte (gelöschte) Session → 401
|
||||||
|
- durch Concurrent-Session-Limit verdrängte Session → 401
|
||||||
|
- → Datei: `apps/web/src/app/api/trpc/[trpc]/route.test.ts` (neu oder ergänzen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue #24 — Docker-Setup host-unabhängig
|
||||||
|
|
||||||
|
- [ ] **Task 13:** `Dockerfile.dev` prüfen und absichern:
|
||||||
|
- `pnpm install` muss im Container ablaufen (kein Volume-Mount der Host-`node_modules`)
|
||||||
|
- `prisma generate` muss Teil des Starts sein (nicht als Host-Voraussetzung)
|
||||||
|
- Fehlende Systempakete (z. B. OpenSSL für Prisma) explizit installieren
|
||||||
|
- → Datei: `Dockerfile.dev`
|
||||||
|
|
||||||
|
- [ ] **Task 14:** `Dockerfile.prod` prüfen:
|
||||||
|
- Multi-Stage-Build: Build-Stage hat pnpm + alle Dev-Deps; Runtime-Stage nur Prod-Artefakte
|
||||||
|
- Generierte Prisma-Artefakte (`node_modules/.prisma`) korrekt aus Build-Stage kopiert
|
||||||
|
- → Datei: `Dockerfile.prod`
|
||||||
|
|
||||||
|
- [ ] **Task 15:** `docker-compose.yml` absichern:
|
||||||
|
- `node_modules`-Volume-Override korrekt gesetzt damit Host-Modules nicht reinmappen
|
||||||
|
- `app-dev-start.sh` ausführbar und alle Schritte (generate, migrate, start) enthalten
|
||||||
|
- → Dateien: `docker-compose.yml`, `tooling/docker/app-dev-start.sh`
|
||||||
|
|
||||||
|
- [ ] **Task 16:** Frischer-Checkout-Smoke-Test dokumentieren:
|
||||||
|
```bash
|
||||||
|
git clone … && cd capakraken
|
||||||
|
docker compose up --build
|
||||||
|
# → App erreichbar, Login funktioniert, keine Host-Abhängigkeiten
|
||||||
|
```
|
||||||
|
→ Schritt in `docs/` oder `README.md` festhalten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abhängigkeiten
|
||||||
|
|
||||||
|
```
|
||||||
|
Task 1 → Task 2 (Tests setzen fertige Impl voraus)
|
||||||
|
Task 3 → Task 4 → Task 5 (Dep-Install → Impl → Test)
|
||||||
|
Task 6 → Task 7 → Task 8 (Guard-Lib → Integration → Tests)
|
||||||
|
Task 9 → Task 10 (CSP-Änderung → Smoke-Test)
|
||||||
|
Task 11 → Task 12 (Session-Impl → Tests)
|
||||||
|
Task 13–16 unabhängig von allen anderen (Docker-only)
|
||||||
|
|
||||||
|
Parallel möglich (nach Task-Gruppe):
|
||||||
|
- #21 (Tasks 1–2) || #19 (Tasks 3–5) || #24 (Tasks 13–16)
|
||||||
|
- #20 (Tasks 6–8) || #22 (Tasks 9–10) — erst nachdem #21/#19 fertig sind (separater Branch)
|
||||||
|
- #23 (Tasks 11–12) — zuletzt, da Auth-Pfad berührt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] `pnpm test:unit` läuft grün
|
||||||
|
- [ ] `pnpm --filter @capakraken/web exec tsc --noEmit` — keine neuen Errors
|
||||||
|
- [ ] `pnpm lint` — sauber
|
||||||
|
- [ ] **#21:** `/api/perf` ohne `CRON_SECRET` gibt 401/403, Query-Token wird nicht mehr akzeptiert
|
||||||
|
- [ ] **#19:** `MfaSetup` macht keinen Request an `qrserver.com` oder `chart.googleapis.com`
|
||||||
|
- [ ] **#20:** Webhook-Save und Dispatch für `http://127.0.0.1/…` gibt `BAD_REQUEST`
|
||||||
|
- [ ] **#22:** Browser-DevTools zeigt keine CSP-Violations für Haupt-User-Flows in Production-Mode
|
||||||
|
- [ ] **#23:** Request mit gelöschter `ActiveSession`-`jti` gibt 401
|
||||||
|
- [ ] **#24:** `docker compose up --build` auf sauberem Checkout bootet die App ohne Host-Abhängigkeiten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risiken & offene Fragen
|
||||||
|
|
||||||
|
| Risiko | Einschätzung | Maßnahme |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| CSP-Nonces in Next.js 15: `<Script>` und Tailwind CSS benötigen ggf. Nonces | Mittel | Vor Task 9 in Next.js-15-Doku recherchieren; ggf. nur `unsafe-eval` entfernen als erster Schritt |
|
||||||
|
| Session-Registry-Check (#23) erhöht DB-Load: jeder tRPC-Request = 1 DB-Read | Mittel | Redis-Cache mit kurzer TTL (30s) als Opt-in; erst messen ob nötig |
|
||||||
|
| SSRF-Guard DNS-Lookup: async, könnte Race-Condition durch DNS-Rebinding haben | Niedrig | Nach DNS-Lookup Socket-Verbindung ebenfalls gegen IP prüfen (defense-in-depth) |
|
||||||
|
| Docker #24: `node_modules`-Volume-Semantik bei pnpm-Workspaces komplex | Mittel | Symlink-Struktur von pnpm in Container testen; ggf. `--shamefully-hoist` Flag |
|
||||||
|
| `jti` im Session-Token: Auth.js-Version muss `jti` ins JWT schreiben | Offen | In `auth.ts` prüfen ob `token.jti` tatsächlich im JWT-Callback persistiert wird |
|
||||||
Generated
+164
@@ -85,6 +85,9 @@ importers:
|
|||||||
'@trpc/server':
|
'@trpc/server':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.11.0(typescript@5.9.3)
|
version: 11.11.0(typescript@5.9.3)
|
||||||
|
'@types/qrcode':
|
||||||
|
specifier: ^1.5.6
|
||||||
|
version: 1.5.6
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@@ -106,6 +109,9 @@ importers:
|
|||||||
otpauth:
|
otpauth:
|
||||||
specifier: ^9.5.0
|
specifier: ^9.5.0
|
||||||
version: 9.5.0
|
version: 9.5.0
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
@@ -1940,6 +1946,9 @@ packages:
|
|||||||
'@types/pg@8.15.6':
|
'@types/pg@8.15.6':
|
||||||
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
|
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||||
|
|
||||||
'@types/react-dom@19.2.3':
|
'@types/react-dom@19.2.3':
|
||||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2368,6 +2377,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
camelcase@5.3.1:
|
||||||
|
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001776:
|
caniuse-lite@1.0.30001776:
|
||||||
resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==}
|
resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==}
|
||||||
|
|
||||||
@@ -2400,6 +2413,9 @@ packages:
|
|||||||
client-only@0.0.1:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||||
|
|
||||||
clone@2.1.2:
|
clone@2.1.2:
|
||||||
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
|
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@@ -2575,6 +2591,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
decimal.js-light@2.5.1:
|
decimal.js-light@2.5.1:
|
||||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||||
|
|
||||||
@@ -2607,6 +2627,9 @@ packages:
|
|||||||
didyoumean@1.2.2:
|
didyoumean@1.2.2:
|
||||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
dlv@1.1.3:
|
dlv@1.1.3:
|
||||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||||
|
|
||||||
@@ -2866,6 +2889,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
find-up@5.0.0:
|
find-up@5.0.0:
|
||||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2951,6 +2978,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
get-caller-file@2.0.5:
|
||||||
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
|
|
||||||
get-intrinsic@1.3.0:
|
get-intrinsic@1.3.0:
|
||||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3339,6 +3370,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
|
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
|
||||||
engines: {node: '>=6.11.5'}
|
engines: {node: '>=6.11.5'}
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3640,14 +3675,26 @@ packages:
|
|||||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
p-limit@3.1.0:
|
p-limit@3.1.0:
|
||||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
p-locate@5.0.0:
|
p-locate@5.0.0:
|
||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
p-try@2.2.0:
|
||||||
|
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
package-json-from-dist@1.0.1:
|
package-json-from-dist@1.0.1:
|
||||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
@@ -3740,6 +3787,10 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
pngjs@5.0.0:
|
||||||
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
polished@4.3.1:
|
polished@4.3.1:
|
||||||
resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==}
|
resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3857,6 +3908,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
@@ -3973,6 +4029,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
require-directory@2.1.1:
|
||||||
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
require-from-string@2.0.2:
|
require-from-string@2.0.2:
|
||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3981,6 +4041,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
|
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
|
||||||
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
|
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0:
|
||||||
|
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||||
|
|
||||||
reselect@5.1.1:
|
reselect@5.1.1:
|
||||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||||
|
|
||||||
@@ -4064,6 +4127,9 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
set-blocking@2.0.0:
|
||||||
|
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4567,6 +4633,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
|
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|
||||||
which-typed-array@1.1.20:
|
which-typed-array@1.1.20:
|
||||||
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
|
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4585,6 +4654,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4603,9 +4676,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
|
y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
|
||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
yocto-queue@0.1.0:
|
yocto-queue@0.1.0:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -6100,6 +6184,10 @@ snapshots:
|
|||||||
pg-protocol: 1.13.0
|
pg-protocol: 1.13.0
|
||||||
pg-types: 2.2.0
|
pg-types: 2.2.0
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.13
|
||||||
|
|
||||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
@@ -6644,6 +6732,8 @@ snapshots:
|
|||||||
|
|
||||||
camelcase-css@2.0.1: {}
|
camelcase-css@2.0.1: {}
|
||||||
|
|
||||||
|
camelcase@5.3.1: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001776: {}
|
caniuse-lite@1.0.30001776: {}
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
@@ -6683,6 +6773,12 @@ snapshots:
|
|||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
wrap-ansi: 6.2.0
|
||||||
|
|
||||||
clone@2.1.2: {}
|
clone@2.1.2: {}
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
@@ -6831,6 +6927,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
decimal.js-light@2.5.1: {}
|
decimal.js-light@2.5.1: {}
|
||||||
|
|
||||||
deep-eql@5.0.2: {}
|
deep-eql@5.0.2: {}
|
||||||
@@ -6858,6 +6956,8 @@ snapshots:
|
|||||||
|
|
||||||
didyoumean@1.2.2: {}
|
didyoumean@1.2.2: {}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
dlv@1.1.3: {}
|
dlv@1.1.3: {}
|
||||||
|
|
||||||
doctrine@2.1.0:
|
doctrine@2.1.0:
|
||||||
@@ -7239,6 +7339,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
locate-path: 5.0.0
|
||||||
|
path-exists: 4.0.0
|
||||||
|
|
||||||
find-up@5.0.0:
|
find-up@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
locate-path: 6.0.0
|
locate-path: 6.0.0
|
||||||
@@ -7325,6 +7430,8 @@ snapshots:
|
|||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
|
|
||||||
|
get-caller-file@2.0.5: {}
|
||||||
|
|
||||||
get-intrinsic@1.3.0:
|
get-intrinsic@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
@@ -7735,6 +7842,10 @@ snapshots:
|
|||||||
|
|
||||||
loader-runner@4.3.1: {}
|
loader-runner@4.3.1: {}
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
p-locate: 4.1.0
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
@@ -7995,14 +8106,24 @@ snapshots:
|
|||||||
object-keys: 1.1.1
|
object-keys: 1.1.1
|
||||||
safe-push-apply: 1.0.0
|
safe-push-apply: 1.0.0
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
p-try: 2.2.0
|
||||||
|
|
||||||
p-limit@3.1.0:
|
p-limit@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
yocto-queue: 0.1.0
|
yocto-queue: 0.1.0
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
p-limit: 2.3.0
|
||||||
|
|
||||||
p-locate@5.0.0:
|
p-locate@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
|
|
||||||
|
p-try@2.2.0: {}
|
||||||
|
|
||||||
package-json-from-dist@1.0.1: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
pako@0.2.9: {}
|
pako@0.2.9: {}
|
||||||
@@ -8085,6 +8206,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
|
|
||||||
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
polished@4.3.1:
|
polished@4.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.28.6
|
'@babel/runtime': 7.28.6
|
||||||
@@ -8177,6 +8300,12 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
dependencies:
|
||||||
|
dijkstrajs: 1.0.3
|
||||||
|
pngjs: 5.0.0
|
||||||
|
yargs: 15.4.1
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
queue@6.0.2:
|
queue@6.0.2:
|
||||||
@@ -8322,6 +8451,8 @@ snapshots:
|
|||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
set-function-name: 2.0.2
|
set-function-name: 2.0.2
|
||||||
|
|
||||||
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
require-in-the-middle@8.0.1:
|
require-in-the-middle@8.0.1:
|
||||||
@@ -8331,6 +8462,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
require-main-filename@2.0.0: {}
|
||||||
|
|
||||||
reselect@5.1.1: {}
|
reselect@5.1.1: {}
|
||||||
|
|
||||||
resize-observer-polyfill@1.5.1: {}
|
resize-observer-polyfill@1.5.1: {}
|
||||||
@@ -8432,6 +8565,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.7.4: {}
|
semver@7.7.4: {}
|
||||||
|
|
||||||
|
set-blocking@2.0.0: {}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-data-property: 1.1.4
|
define-data-property: 1.1.4
|
||||||
@@ -9078,6 +9213,8 @@ snapshots:
|
|||||||
is-weakmap: 2.0.2
|
is-weakmap: 2.0.2
|
||||||
is-weakset: 2.0.4
|
is-weakset: 2.0.4
|
||||||
|
|
||||||
|
which-module@2.0.1: {}
|
||||||
|
|
||||||
which-typed-array@1.1.20:
|
which-typed-array@1.1.20:
|
||||||
dependencies:
|
dependencies:
|
||||||
available-typed-arrays: 1.0.7
|
available-typed-arrays: 1.0.7
|
||||||
@@ -9099,6 +9236,12 @@ snapshots:
|
|||||||
|
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 4.3.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -9117,8 +9260,29 @@ snapshots:
|
|||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
|
y18n@4.0.3: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
dependencies:
|
||||||
|
camelcase: 5.3.1
|
||||||
|
decamelize: 1.2.0
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
dependencies:
|
||||||
|
cliui: 6.0.0
|
||||||
|
decamelize: 1.2.0
|
||||||
|
find-up: 4.1.0
|
||||||
|
get-caller-file: 2.0.5
|
||||||
|
require-directory: 2.1.1
|
||||||
|
require-main-filename: 2.0.0
|
||||||
|
set-blocking: 2.0.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
which-module: 2.0.1
|
||||||
|
y18n: 4.0.3
|
||||||
|
yargs-parser: 18.1.3
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
yoga-layout@3.2.1: {}
|
yoga-layout@3.2.1: {}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
# Regenerate Prisma client (needed after bind-mount overlays the image layer)
|
||||||
pnpm --filter @capakraken/db db:generate
|
pnpm --filter @capakraken/db db:generate
|
||||||
|
|
||||||
|
# Run pending migrations so a fresh checkout boots against a current schema
|
||||||
|
pnpm --filter @capakraken/db db:migrate:deploy
|
||||||
|
|
||||||
pnpm check:exports
|
pnpm check:exports
|
||||||
pnpm check:imports
|
pnpm check:imports
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user