diff --git a/package.json b/package.json index 0df418d..fb2f3a2 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "clsx": "^2.1.1", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.41.0", + "jsonwebtoken": "^9.0.3", "lucide-react": "^1.17.0", "motion": "^12.40.0", "next": "^15.2.3", @@ -55,6 +56,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.0.15", "@types/better-sqlite3": "^7.6.13", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.14.10", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 608d275..44b9ff4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: drizzle-orm: specifier: ^0.41.0 version: 0.41.0(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1) + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 lucide-react: specifier: ^1.17.0 version: 1.17.0(react@19.2.7) @@ -99,6 +102,9 @@ importers: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/node': specifier: ^20.14.10 version: 20.19.42 @@ -1631,6 +1637,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1783,6 +1792,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2090,6 +2102,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + eciesjs@0.4.18: resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -2545,6 +2560,16 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -2630,6 +2655,27 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -4659,6 +4705,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 20.19.42 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -4814,6 +4865,8 @@ snapshots: node-releases: 2.0.47 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -4980,6 +5033,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + eciesjs@0.4.18: dependencies: '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) @@ -5495,6 +5552,30 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.4 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + kleur@3.0.3: {} kleur@4.1.5: {} @@ -5550,6 +5631,20 @@ snapshots: lines-and-columns@1.2.4: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + log-symbols@6.0.0: dependencies: chalk: 5.6.2 diff --git a/src/app/mock/[projectId]/[...path]/route.ts b/src/app/mock/[projectId]/[...path]/route.ts index 31f41d8..47ef72f 100644 --- a/src/app/mock/[projectId]/[...path]/route.ts +++ b/src/app/mock/[projectId]/[...path]/route.ts @@ -1,8 +1,9 @@ import { type NextRequest, NextResponse } from "next/server"; import { db } from "~/server/db"; -import { endpoints_table, projects_table } from "~/server/db/schema"; +import { auth_configs_table, endpoints_table, projects_table } from "~/server/db/schema"; import { and, eq } from "drizzle-orm"; import { getData, generateAndSaveData } from "~/lib/endpoint-data-store"; +import jwt from "jsonwebtoken"; interface Params { projectId: string; @@ -21,7 +22,7 @@ function matchPath(pattern: string, incoming: string): boolean { return regex.test(incoming); } -async function handler(req: NextRequest, { params }: { params: Params }) { +async function handler(req: NextRequest, { params }: { params: Params | Promise }) { const { projectId, path } = await params; const incomingPath = "/" + path.join("/"); const method = req.method; @@ -33,7 +34,6 @@ async function handler(req: NextRequest, { params }: { params: Params }) { const project = await db.query.projects_table.findFirst({ where: eq(projects_table.id, projectId), }); - if (!project) { return NextResponse.json({ error: "Project not found" }, { status: 404 }); } @@ -46,13 +46,46 @@ async function handler(req: NextRequest, { params }: { params: Params }) { const candidates = await db.query.endpoints_table.findMany({ where: and(eq(endpoints_table.projectId, projectId), eq(endpoints_table.method, method)), }); - const endpoint = candidates.find((e) => matchPath(e.path, strippedPath)); - if (!endpoint) { return NextResponse.json({ error: "No matching endpoint found" }, { status: 404 }); } + const authConfig = await db.query.auth_configs_table.findFirst({ + where: eq(auth_configs_table.endpointId, endpoint.id), + }); + + if (authConfig?.isLoginEndpoint) { + const data = + getData(projectId, endpoint.id) ?? + generateAndSaveData( + projectId, + endpoint.id, + endpoint.responseSchema, + endpoint.responseCount ?? 1, + ); + + const token = jwt.sign({ sub: endpoint.id }, project.secret, { + expiresIn: authConfig.tokenExpirySeconds, + }); + + return NextResponse.json({ token, data }, { status: endpoint.statusCode }); + } + + if (authConfig?.requiresAuth) { + const header = req.headers.get("authorization"); + const token = header?.startsWith("Bearer ") ? header.slice(7) : null; + + if (!token) { + return NextResponse.json({ error: "Missing token" }, { status: 401 }); + } + try { + jwt.verify(token, project.secret); + } catch { + return NextResponse.json({ error: "Invalid or expired token" }, { status: 401 }); + } + } + if (endpoint.delayMs > 0) { await new Promise((r) => setTimeout(r, endpoint.delayMs)); } diff --git a/src/components/code-block.tsx b/src/components/code-block.tsx index 93dff43..07f2ae4 100644 --- a/src/components/code-block.tsx +++ b/src/components/code-block.tsx @@ -29,7 +29,7 @@ function Codebar({ lang, code }: CodebarProps) { }; return ( -
+
{lang} + +
+
+

+ JWT tokens generated by this endpoint will expire after this duration. + Default: 3600s (1 hour). +

+ + )} + + +
+ +
+ + ); +} diff --git a/src/modules/workspace/ui/components/dashboard/endpointBar.tsx b/src/modules/workspace/ui/components/dashboard/endpointBar.tsx index 938424d..171ed74 100644 --- a/src/modules/workspace/ui/components/dashboard/endpointBar.tsx +++ b/src/modules/workspace/ui/components/dashboard/endpointBar.tsx @@ -1,4 +1,5 @@ "use client"; + import { useState } from "react"; import { toast } from "sonner"; import { SchemaBuilder } from "./schemaBuilder"; @@ -6,9 +7,11 @@ import { SchemaBuilder } from "./schemaBuilder"; import { Card, CardContent } from "~/components/ui/card"; import { Dialog, DialogContent, DialogTitle } from "~/components/ui/dialog"; import { ConfirmDialog } from "~/components/confirm-dialog"; -import { Copy, Check, Settings2 } from "lucide-react"; +import { Badge } from "~/components/ui/badge"; +import { Copy, Check, Settings2, Shield, KeyRound, Lock, Settings } from "lucide-react"; import { Button } from "~/components/ui/button"; import { MethodBadge } from "~/modules/workspace/ui/components/sidebar/method-badge"; +import { ProjectSettingsDialog } from "~/modules/workspace/ui/components/sidebar/project-settings-dialog"; import { buildMockPath } from "~/lib/mock-path"; import { useUpdateEndpoint } from "~/hooks/use-endpoints"; @@ -28,6 +31,8 @@ interface EndpointBarProps { export const EndpointBar = ({ projectId, mockOrigin, endpoint, project }: EndpointBarProps) => { const [isSchemaBuilderOpen, setIsSchemaBuilderOpen] = useState(false); + const [activeTab, setActiveTab] = useState<"schema" | "auth">("schema"); + const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); const [copied, setCopied] = useState(false); const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false); const updateEndpoint = useUpdateEndpoint(); @@ -55,9 +60,13 @@ export const EndpointBar = ({ projectId, mockOrigin, endpoint, project }: Endpoi } }; + const handleOpenModal = (tab: "schema" | "auth") => { + setActiveTab(tab); + setIsSchemaBuilderOpen(true); + }; + const handleResetAll = () => { if (!endpoint) return; - setIsResetConfirmOpen(true); }; @@ -88,13 +97,40 @@ export const EndpointBar = ({ projectId, mockOrigin, endpoint, project }: Endpoi <>
- - {/* Heading */} -

API endpoint

+ + {/* Header Row */} +
+
+

+ API endpoint +

+ {endpoint?.authConfig?.isLoginEndpoint && ( + + LOGIN + + )} + {endpoint?.authConfig?.requiresAuth && ( + + PROTECTED + + )} +
+ + +
{/* Endpoint URL Presentation */}
-
+
{endpoint && }
@@ -126,19 +162,30 @@ export const EndpointBar = ({ projectId, mockOrigin, endpoint, project }: Endpoi {/* Controls Bar */}
- {/* Primary Action */} - + {/* Primary Action Buttons */} +
+ - {/* Utility Bulk Actions */} + +
+ {/* Utility Bulk Actions */}
+ + {/* Endpoint Configuration Dialog */} - - Schema Builder Resource Manager + + Endpoint Settings Manager -
+
{endpoint && ( setIsSchemaBuilderOpen(false)} /> )} @@ -166,6 +216,14 @@ export const EndpointBar = ({ projectId, mockOrigin, endpoint, project }: Endpoi
+ {/* Project Settings Dialog */} + + + {/* Reset Confirmation Dialog */} void; } @@ -68,7 +71,8 @@ function FieldLabel({ children, tooltip }: { children: React.ReactNode; tooltip: ); } -export function SchemaBuilder({ endpoint, onSuccess }: SchemaBuilderProps) { +export function SchemaBuilder({ endpoint, initialTab = "schema", onSuccess }: SchemaBuilderProps) { + const [activeTab, setActiveTab] = useState<"schema" | "auth">(initialTab); const [resourceName, setResourceName] = useState(endpoint.name); const [nameError, setNameError] = useState(null); const [delayMs, setDelayMs] = useState(endpoint.delayMs); @@ -80,6 +84,10 @@ export function SchemaBuilder({ endpoint, onSuccess }: SchemaBuilderProps) { const updateEndpoint = useUpdateEndpoint(); + useEffect(() => { + setActiveTab(initialTab); + }, [initialTab]); + useEffect(() => { setResourceName(endpoint.name); setNameError(null); @@ -163,240 +171,282 @@ export function SchemaBuilder({ endpoint, onSuccess }: SchemaBuilderProps) { return ( - - - - Configure resource - - - Define the endpoint's identity, behavior, and response shape. - - - - - {/* Resource name */} -
- - { - setResourceName(e.target.value); - if (nameError) setNameError(null); - }} - aria-invalid={!!nameError} - className="h-11 border-0 bg-muted font-mono text-base shadow-none focus-visible:ring-2 focus-visible:ring-ring aria-invalid:ring-2 aria-invalid:ring-destructive/50" - /> - {nameError && ( -

{nameError}

- )} -
- - - {/* Behavior config */} -
-
- - Delay - -
+ setActiveTab(v as "schema" | "auth")} + orientation="horizontal" + className="w-full flex-1 flex flex-col gap-0" + > +
+ + + Schema & Behavior + + + Authentication + + +
+ + + + + + Configure resource + + + Define the endpoint's identity, behavior, and response shape. + + + + + {/* Resource name */} +
+ setDelayMs(Number(e.target.value))} - className="h-10 border-0 bg-muted pr-10 font-mono shadow-none focus-visible:ring-2 focus-visible:ring-ring" + id="resource-name" + placeholder="users, comments, articles..." + value={resourceName} + onChange={(e) => { + setResourceName(e.target.value); + if (nameError) setNameError(null); + }} + aria-invalid={!!nameError} + className="h-11 border-0 bg-muted font-mono text-base shadow-none focus-visible:ring-2 focus-visible:ring-ring aria-invalid:ring-2 aria-invalid:ring-destructive/50" /> - - ms - + {nameError && ( +

+ {nameError} +

+ )}
-
-
- - Failure rate - -
- - setFailureRate(Array.isArray(value) ? value[0]! : value) - } - className="flex-1" - /> - - {failureRate.toFixed(1)} - + + + {/* Behavior config */} +
+
+ + Delay + +
+ setDelayMs(Number(e.target.value))} + className="h-10 border-0 bg-muted pr-10 font-mono shadow-none focus-visible:ring-2 focus-visible:ring-ring" + /> + + ms + +
+
+ +
+ + Failure rate + +
+ + setFailureRate( + Array.isArray(value) ? value[0]! : value, + ) + } + className="flex-1" + /> + + {failureRate.toFixed(1)} + +
+
+ +
+ + Response count + + setResponseCount(Number(e.target.value))} + placeholder="10" + className="h-10 border-0 bg-muted font-mono shadow-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
-
-
- - Response count - - setResponseCount(Number(e.target.value))} - placeholder="10" - className="h-10 border-0 bg-muted font-mono shadow-none focus-visible:ring-2 focus-visible:ring-ring" - /> -
-
- - - - {/* Schema fields */} -
-
- - Fields schema - - - {fields.length} {fields.length === 1 ? "field" : "fields"} - -
+ -
- {fields.length > 0 && ( -
-
Field name
-
Data type
-
+ {/* Schema fields */} +
+
+ + Fields schema + + + {fields.length} {fields.length === 1 ? "field" : "fields"} +
- )} -
- {fields.map((field) => ( -
- - updateField( - field.id, - "fieldName", - e.target.value, - ) - } - className="h-10 border-0 bg-muted font-mono text-sm shadow-none focus-visible:ring-2 focus-visible:ring-ring" - /> - - - - - removeField(field.id)} - className="h-10 w-10 shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive disabled:opacity-30" - > - - + + updateField( + field.id, + "fieldName", + e.target.value, + ) } + className="h-10 border-0 bg-muted font-mono text-sm shadow-none focus-visible:ring-2 focus-visible:ring-ring" /> - - {fields.length === 1 - ? "At least one field is required" - : "Remove field"} - - -
- ))} + + + + + + removeField(field.id) + } + className="h-10 w-10 shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive disabled:opacity-30" + > + + + } + /> + + {fields.length === 1 + ? "At least one field is required" + : "Remove field"} + + +
+ ))} +
+ +
- +
+ + + {/* Live sample data preview */} +
+
+ + + Live preview + +
+
+                                    {JSON.stringify(previewData, null, 2)}
+                                
+
+ + +
-
+ + - - - {/* Live sample data preview */} -
-
- - - Live preview - -
- -
-                                {JSON.stringify(previewData, null, 2)}
-                            
-
-
- - -
- -
- + + + + ); } diff --git a/src/modules/workspace/ui/components/dashboard/schemaPreview.tsx b/src/modules/workspace/ui/components/dashboard/schemaPreview.tsx index ea38518..2820e5f 100644 --- a/src/modules/workspace/ui/components/dashboard/schemaPreview.tsx +++ b/src/modules/workspace/ui/components/dashboard/schemaPreview.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Code2, RotateCw } from "lucide-react"; +import { Code2, RotateCw, ShieldAlert } from "lucide-react"; import { Card, CardContent, CardHeader } from "~/components/ui/card"; import { CodeBlock } from "~/components/code-block"; import { Button } from "~/components/ui/button"; @@ -16,19 +16,28 @@ interface SchemaPreviewProps { delayMs: number; failureRate: number; responseCount: number; + authConfig?: { + isLoginEndpoint?: boolean; + requiresAuth?: boolean; + } | null; } | null | undefined; fetchUrl: string; + /** JWT bearer token to use when the endpoint requiresAuth */ + bearerToken?: string | null; } -export function SchemaPreview({ endpoint, fetchUrl }: SchemaPreviewProps) { +export function SchemaPreview({ endpoint, fetchUrl, bearerToken }: SchemaPreviewProps) { const [liveData, setLiveData] = useState(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const [progress, setProgress] = useState(0); const animationFrameRef = useRef(null); + const requiresAuth = !!endpoint?.authConfig?.requiresAuth; + const isLoginEndpoint = !!endpoint?.authConfig?.isLoginEndpoint; + const fields = endpoint?.responseSchema && typeof endpoint.responseSchema === "object" ? Object.entries(endpoint.responseSchema as Record) @@ -58,7 +67,12 @@ export function SchemaPreview({ endpoint, fetchUrl }: SchemaPreviewProps) { animationFrameRef.current = window.requestAnimationFrame(tick); try { - const res = await fetch(fetchUrl, { method: endpoint.method }); + const headers: HeadersInit = {}; + if (requiresAuth && bearerToken) { + headers["Authorization"] = `Bearer ${bearerToken}`; + } + + const res = await fetch(fetchUrl, { method: endpoint.method, headers }); const json = await res.json().catch(() => null); if (res.status === 500 && json) { setLiveData(json); @@ -84,7 +98,7 @@ export function SchemaPreview({ endpoint, fetchUrl }: SchemaPreviewProps) { setProgress(100); setIsLoading(false); } - }, [endpoint, fetchUrl]); + }, [endpoint, fetchUrl, requiresAuth, bearerToken]); useEffect(() => { void loadSample(); @@ -109,6 +123,38 @@ export function SchemaPreview({ endpoint, fetchUrl }: SchemaPreviewProps) { + {/* Auth notice banners */} + {requiresAuth && !bearerToken && ( +
+ + + This endpoint requires authentication. No login endpoint was found + in this project — add one to auto-fetch a token for the preview. + +
+ )} + {requiresAuth && !!bearerToken && ( +
+ + + Protected endpoint — preview is fetched with a valid bearer token + from your project's login endpoint. + +
+ )} + {isLoginEndpoint && ( +
+ + + Login endpoint — response includes a signed JWT{" "} + + token + {" "} + field alongside the mock data. + +
+ )} + {/* Field summary table */}
@@ -149,7 +195,7 @@ export function SchemaPreview({ endpoint, fetchUrl }: SchemaPreviewProps) { variant="outline" size="sm" onClick={loadSample} - disabled={isLoading} + disabled={isLoading || (requiresAuth && !bearerToken)} className="h-8 gap-2 font-mono text-xs" > void; +} + +export function ProjectSettingsDialog({ + projectId, + open, + onOpenChange, +}: ProjectSettingsDialogProps) { + const { data: project } = useProjectById(projectId); + const updateProject = useUpdateProject(); + + const [title, setTitle] = useState(""); + const [basePath, setBasePath] = useState("/"); + const [secret, setSecret] = useState(""); + const [showSecret, setShowSecret] = useState(false); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (project) { + setTitle(project.title ?? ""); + setBasePath(project.basePath ?? "/"); + setSecret(project.secret ?? ""); + } + }, [project]); + + if (!project) return null; + + const handleCopy = async () => { + if (!secret) return; + try { + await navigator.clipboard.writeText(secret); + setCopied(true); + toast.success("JWT secret copied to clipboard"); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Failed to copy secret"); + } + }; + + const handleRegenerate = () => { + // Generate a cryptographically secure 32-byte hex secret + const array = new Uint8Array(32); + crypto.getRandomValues(array); + const newSecret = Array.from(array, (b) => b.toString(16).padStart(2, "0")).join(""); + + updateProject.mutate( + { id: project.id, secret: newSecret }, + { + onSuccess: (updated) => { + if (updated) setSecret(updated.secret); + toast.success("Project secret regenerated"); + }, + }, + ); + }; + + const handleSaveGeneral = () => { + if (!title.trim()) { + toast.error("Project name is required"); + return; + } + + updateProject.mutate( + { + id: project.id, + title: title.trim(), + basePath: basePath.trim() || "/", + secret: secret.trim() || undefined, + }, + { + onSuccess: () => { + toast.success("Project settings saved"); + onOpenChange(false); + }, + }, + ); + }; + + return ( + + + + + + Project Settings + + + +
+ {/* Project Name & Base Path */} +
+
+ + setTitle(e.target.value)} + placeholder="My API Project" + /> +
+
+ + setBasePath(e.target.value)} + placeholder="/" + className="font-mono text-sm" + /> +
+
+ + + + {/* JWT Secret Section */} +
+
+
+ +

+ Used to sign JWTs for login endpoints & verify auth headers. +

+
+
+ +
+
+ setSecret(e.target.value)} + className="font-mono text-sm pr-10 tracking-wider bg-background" + placeholder="JWT Secret" + /> + +
+ + +
+ +
+ + Length: {secret.length} chars + + + +
+
+
+ + + + + +
+
+ ); +} diff --git a/src/modules/workspace/ui/components/sidebar/sidebar-tree.tsx b/src/modules/workspace/ui/components/sidebar/sidebar-tree.tsx index d7b93c4..a7eff4c 100644 --- a/src/modules/workspace/ui/components/sidebar/sidebar-tree.tsx +++ b/src/modules/workspace/ui/components/sidebar/sidebar-tree.tsx @@ -1,5 +1,6 @@ "use client"; +import { KeyRound, Lock } from "lucide-react"; import { cn } from "~/lib/utils"; import { TreeNode, @@ -82,6 +83,22 @@ export function EndpointTreeNode({ {endpoint.name} )} + {endpoint.authConfig?.isLoginEndpoint && ( + + + + )} + {endpoint.authConfig?.requiresAuth && ( + + + + )} onOpenModal({ kind: "folder", projectId: project.id })} onNewFile={() => onOpenModal({ kind: "endpoint", projectId: project.id })} + onSettings={() => + onOpenModal({ kind: "projectSettings", projectId: project.id }) + } onRename={() => setRenamingId(project.id)} onDelete={() => onDeleteProject(project.id)} /> diff --git a/src/modules/workspace/ui/components/sidebar/tree-node-menu.tsx b/src/modules/workspace/ui/components/sidebar/tree-node-menu.tsx index 655db0d..0474ef6 100644 --- a/src/modules/workspace/ui/components/sidebar/tree-node-menu.tsx +++ b/src/modules/workspace/ui/components/sidebar/tree-node-menu.tsx @@ -7,11 +7,19 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; -import { MoreHorizontalIcon, FolderPlusIcon, PencilIcon, TrashIcon, FilePlus2 } from "lucide-react"; +import { + MoreHorizontalIcon, + FolderPlusIcon, + PencilIcon, + TrashIcon, + FilePlus2, + KeyRound, +} from "lucide-react"; export function TreeNodeMenu({ onNewFolder, onNewFile, + onSettings, onRename, onDelete, showNewFolder = true, @@ -19,6 +27,7 @@ export function TreeNodeMenu({ }: { onNewFolder?: () => void; onNewFile?: () => void; + onSettings?: () => void; onRename: () => void; onDelete: () => void; showNewFolder?: boolean; @@ -56,6 +65,11 @@ export function TreeNodeMenu({ {(showNewFolder || showNewFile) && (onNewFolder ?? onNewFile) && ( )} + {onSettings && ( + + Settings & Secret + + )} Rename diff --git a/src/modules/workspace/ui/components/sidebar/types.ts b/src/modules/workspace/ui/components/sidebar/types.ts index a88bfbe..89d2560 100644 --- a/src/modules/workspace/ui/components/sidebar/types.ts +++ b/src/modules/workspace/ui/components/sidebar/types.ts @@ -5,6 +5,11 @@ export interface Endpoint { name: string; method: HttpMethod; folderId?: string | null; + authConfig?: { + isLoginEndpoint?: boolean; + requiresAuth?: boolean; + tokenExpirySeconds?: number; + } | null; } export interface Collection { @@ -23,6 +28,7 @@ export type FolderRow = { export type ModalTarget = | { kind: "project"; projectId?: never; parentId?: never; folderId?: never } + | { kind: "projectSettings"; projectId: string; parentId?: never; folderId?: never } | { kind: "folder"; projectId: string; parentId?: string } | { kind: "endpoint"; projectId: string; folderId?: string }; diff --git a/src/modules/workspace/ui/components/sidebar/workspace-sidebar.tsx b/src/modules/workspace/ui/components/sidebar/workspace-sidebar.tsx index 7529238..4bd86d0 100644 --- a/src/modules/workspace/ui/components/sidebar/workspace-sidebar.tsx +++ b/src/modules/workspace/ui/components/sidebar/workspace-sidebar.tsx @@ -6,6 +6,7 @@ import { Button } from "~/components/ui/button"; import { ConfirmDialog } from "~/components/confirm-dialog"; import { TreeProvider, TreeView } from "~/components/kibo-ui/tree"; import { CreateFolderDialog, CreateEndpointDialog, CreateProjectDialog } from "./dialogs"; +import { ProjectSettingsDialog } from "./project-settings-dialog"; import { ProjectTreeNode } from "./sidebar-tree"; import { useCreateProject, @@ -195,6 +196,13 @@ export const Sidebar = forwardRef< setModal(null); }} /> + { + if (!open) setModal(null); + }} + /> ); }); diff --git a/src/modules/workspace/ui/layouts/workspace-layout.tsx b/src/modules/workspace/ui/layouts/workspace-layout.tsx index fd83eee..3fb298a 100644 --- a/src/modules/workspace/ui/layouts/workspace-layout.tsx +++ b/src/modules/workspace/ui/layouts/workspace-layout.tsx @@ -1,12 +1,12 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { PanelLeftIcon, Plus } from "lucide-react"; import { cn } from "~/lib/utils"; import { Sidebar, type SidebarHandle } from "../components/sidebar/workspace-sidebar"; import { EndpointBar } from "../components/dashboard/endpointBar"; import { SchemaPreview } from "../components/dashboard/schemaPreview"; import { buildMockUrl, getMockOrigin } from "~/lib/mock-path"; -import { useEndpointById } from "~/hooks/use-endpoints"; +import { useEndpointById, useEndpoints } from "~/hooks/use-endpoints"; import { useProjectById } from "~/hooks/use-projects"; import { ModeToggle } from "~/components/mode-toggle"; @@ -21,6 +21,7 @@ export function ApiClientLayout({ const [selectedEndpointId, setSelectedEndpointId] = useState(null); const [selectedProjectId, setSelectedProjectId] = useState(null); const [mockOrigin, setMockOrigin] = useState(getMockOrigin); + const [bearerToken, setBearerToken] = useState(null); useEffect(() => { setMockOrigin(getMockOrigin()); @@ -33,12 +34,47 @@ export function ApiClientLayout({ const { data: project } = useProjectById(selectedProjectId); const { data: endpoint } = useEndpointById(selectedEndpointId); + const { data: allEndpoints } = useEndpoints(selectedProjectId); const fetchUrl = endpoint && selectedProjectId ? buildMockUrl(mockOrigin, selectedProjectId, project?.basePath, endpoint.path) : ""; + // Find the login endpoint for this project (if any) + const loginEndpoint = allEndpoints?.find((e) => e.authConfig?.isLoginEndpoint); + + // Auto-fetch a bearer token from the login endpoint whenever it changes + const fetchBearerToken = useCallback(async () => { + if (!loginEndpoint || !selectedProjectId) { + setBearerToken(null); + return; + } + try { + const loginUrl = buildMockUrl( + mockOrigin, + selectedProjectId, + project?.basePath, + loginEndpoint.path, + ); + const res = await fetch(loginUrl, { method: loginEndpoint.method }); + if (!res.ok) { + setBearerToken(null); + return; + } + const json = await res.json().catch(() => null); + const token = + json && typeof json === "object" && "token" in json ? String(json.token) : null; + setBearerToken(token); + } catch { + setBearerToken(null); + } + }, [loginEndpoint, selectedProjectId, mockOrigin, project?.basePath]); + + useEffect(() => { + void fetchBearerToken(); + }, [fetchBearerToken]); + return (
{/* Absolute sidebar */} @@ -116,6 +152,7 @@ export function ApiClientLayout({ key={selectedEndpointId} endpoint={endpoint} fetchUrl={fetchUrl} + bearerToken={bearerToken} /> )}
diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 0f27fa2..486f96f 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -3,12 +3,14 @@ import { healthRouter } from "./routers/health"; import { projectRouter } from "./routers/project"; import { folderRouter } from "./routers/folder"; import { endpointRouter } from "./routers/endpoint"; +import { authConfigRouter } from "./routers/auth-config"; export const appRouter = createTRPCRouter({ health: healthRouter, project: projectRouter, folder: folderRouter, endpoint: endpointRouter, + authConfig: authConfigRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/server/api/routers/auth-config.test.ts b/src/server/api/routers/auth-config.test.ts new file mode 100644 index 0000000..6227bba --- /dev/null +++ b/src/server/api/routers/auth-config.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import Database from "better-sqlite3"; +import { TRPCError } from "@trpc/server"; +import * as schema from "~/server/db/schema"; +import { authConfigRouter } from "~/server/api/routers/auth-config"; +import { eq } from "drizzle-orm"; + +function createCtx() { + const sqlite = new Database(":memory:"); + sqlite.pragma("foreign_keys = ON"); + const db = drizzle(sqlite, { schema }); + sqlite.exec(` + CREATE TABLE voidend_projects ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + basePath TEXT NOT NULL DEFAULT '/', + secret TEXT NOT NULL, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + CREATE TABLE voidend_folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + projectId TEXT NOT NULL REFERENCES voidend_projects(id) ON DELETE CASCADE, + parentId TEXT REFERENCES voidend_folders(id) ON DELETE CASCADE, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + CREATE TABLE voidend_endpoints ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + projectId TEXT NOT NULL REFERENCES voidend_projects(id) ON DELETE CASCADE, + folderId TEXT REFERENCES voidend_folders(id) ON DELETE CASCADE, + method TEXT NOT NULL DEFAULT 'GET', + path TEXT NOT NULL, + statusCode INTEGER NOT NULL DEFAULT 200, + responseHeaders TEXT DEFAULT '{}', + delayMs INTEGER NOT NULL DEFAULT 0, + failureRate REAL NOT NULL DEFAULT 0, + responseSchema TEXT NOT NULL DEFAULT '{}', + responseCount INTEGER NOT NULL DEFAULT 1, + errorSchema TEXT DEFAULT NULL, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + CREATE TABLE voidend_auth_configs ( + id TEXT PRIMARY KEY, + endpointId TEXT NOT NULL REFERENCES voidend_endpoints(id) ON DELETE CASCADE, + isLoginEndpoint INTEGER NOT NULL DEFAULT 0, + requiresAuth INTEGER NOT NULL DEFAULT 0, + tokenExpirySeconds INTEGER NOT NULL DEFAULT 3600, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + `); + return { db, headers: new Headers() }; +} + +async function insertProject(db: ReturnType["db"], overrides = {}) { + const [project] = await db + .insert(schema.projects_table) + .values({ title: "P", secret: "test-secret", ...overrides }) + .returning(); + return project!; +} + +async function insertEndpoint( + db: ReturnType["db"], + projectId: string, + overrides = {}, +) { + const [endpoint] = await db + .insert(schema.endpoints_table) + .values({ name: "Users", projectId, path: "/users", ...overrides }) + .returning(); + return endpoint!; +} + +describe("authConfigRouter", () => { + let ctx: ReturnType; + let caller: ReturnType; + let projectId: string; + let endpointId: string; + + beforeEach(async () => { + ctx = createCtx(); + caller = authConfigRouter.createCaller(ctx); + projectId = (await insertProject(ctx.db)).id; + endpointId = (await insertEndpoint(ctx.db, projectId)).id; + }); + + describe("upsert", () => { + it("creates a new auth config", async () => { + const config = await caller.upsert({ endpointId, requiresAuth: true }); + expect(config?.endpointId).toBe(endpointId); + expect(config?.requiresAuth).toBe(true); + expect(config?.isLoginEndpoint).toBe(false); + }); + + it("updates an existing config", async () => { + const first = await caller.upsert({ endpointId, isLoginEndpoint: true }); + const second = await caller.upsert({ + endpointId, + isLoginEndpoint: true, + tokenExpirySeconds: 7200, + }); + expect(first?.isLoginEndpoint).toBe(true); + expect(second?.tokenExpirySeconds).toBe(7200); + }); + + it("rejects isLoginEndpoint and requiresAuth both true", async () => { + await expect( + caller.upsert({ endpointId, isLoginEndpoint: true, requiresAuth: true }), + ).rejects.toThrow(TRPCError); + }); + + it("throws NOT_FOUND for a nonexistent endpoint", async () => { + await expect( + caller.upsert({ endpointId: crypto.randomUUID(), requiresAuth: true }), + ).rejects.toThrow(TRPCError); + }); + + it("defaults isLoginEndpoint and requiresAuth to false", async () => { + const config = await caller.upsert({ endpointId }); + expect(config?.isLoginEndpoint).toBe(false); + expect(config?.requiresAuth).toBe(false); + }); + }); + + describe("getByEndpoint", () => { + it("returns null when no config exists", async () => { + expect(await caller.getByEndpoint({ endpointId })).toBeNull(); + }); + + it("returns the config when one exists", async () => { + await caller.upsert({ endpointId, requiresAuth: true }); + const config = await caller.getByEndpoint({ endpointId }); + expect(config?.requiresAuth).toBe(true); + }); + + it("scopes correctly across multiple endpoints", async () => { + const other = await insertEndpoint(ctx.db, projectId, { + path: "/login", + method: "POST", + }); + await caller.upsert({ endpointId, requiresAuth: true }); + await caller.upsert({ endpointId: other.id, isLoginEndpoint: true }); + + const a = await caller.getByEndpoint({ endpointId }); + const b = await caller.getByEndpoint({ endpointId: other.id }); + expect(a?.requiresAuth).toBe(true); + expect(b?.isLoginEndpoint).toBe(true); + }); + }); + + describe("delete", () => { + it("removes the auth config", async () => { + await caller.upsert({ endpointId, requiresAuth: true }); + await caller.delete({ endpointId }); + expect(await caller.getByEndpoint({ endpointId })).toBeNull(); + }); + + it("is a no-op when no config exists", async () => { + await expect(caller.delete({ endpointId })).resolves.not.toThrow(); + }); + + it("cascade-deletes when the endpoint itself is deleted", async () => { + await caller.upsert({ endpointId, requiresAuth: true }); + await ctx.db + .delete(schema.endpoints_table) + .where(eq(schema.endpoints_table.id, endpointId)); + expect(await caller.getByEndpoint({ endpointId })).toBeNull(); + }); + }); +}); diff --git a/src/server/api/routers/auth-config.ts b/src/server/api/routers/auth-config.ts new file mode 100644 index 0000000..7d6c8b5 --- /dev/null +++ b/src/server/api/routers/auth-config.ts @@ -0,0 +1,69 @@ +import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; +import z from "zod"; +import { auth_configs_table } from "~/server/db/schema"; +import { eq } from "drizzle-orm"; +import { TRPCError } from "@trpc/server"; + +export const authConfigRouter = createTRPCRouter({ + upsert: publicProcedure + .input( + z.object({ + endpointId: z.string().uuid(), + isLoginEndpoint: z.boolean().default(false), + requiresAuth: z.boolean().default(false), + tokenExpirySeconds: z.number().int().min(1).default(3600), + }), + ) + .mutation(async ({ ctx, input }) => { + if (input.isLoginEndpoint && input.requiresAuth) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "An endpoint cannot be both a login endpoint and require auth.", + }); + } + + const endpoint = await ctx.db.query.endpoints_table.findFirst({ + where: (e, { eq }) => eq(e.id, input.endpointId), + }); + if (!endpoint) { + throw new TRPCError({ code: "NOT_FOUND", message: "Endpoint not found." }); + } + + const existing = await ctx.db.query.auth_configs_table.findFirst({ + where: (a, { eq }) => eq(a.endpointId, input.endpointId), + }); + + if (existing) { + const [updated] = await ctx.db + .update(auth_configs_table) + .set({ + isLoginEndpoint: input.isLoginEndpoint, + requiresAuth: input.requiresAuth, + tokenExpirySeconds: input.tokenExpirySeconds, + }) + .where(eq(auth_configs_table.endpointId, input.endpointId)) + .returning(); + return updated; + } + + const [created] = await ctx.db.insert(auth_configs_table).values(input).returning(); + return created; + }), + + getByEndpoint: publicProcedure + .input(z.object({ endpointId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const config = await ctx.db.query.auth_configs_table.findFirst({ + where: (a, { eq }) => eq(a.endpointId, input.endpointId), + }); + return config ?? null; + }), + + delete: publicProcedure + .input(z.object({ endpointId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + await ctx.db + .delete(auth_configs_table) + .where(eq(auth_configs_table.endpointId, input.endpointId)); + }), +}); diff --git a/src/server/api/routers/endpoint.test.ts b/src/server/api/routers/endpoint.test.ts index f27cc73..632675a 100644 --- a/src/server/api/routers/endpoint.test.ts +++ b/src/server/api/routers/endpoint.test.ts @@ -25,6 +25,7 @@ function createCtx() { title TEXT NOT NULL, description TEXT, basePath TEXT NOT NULL DEFAULT '/', + secret TEXT NOT NULL, createdAt INTEGER NOT NULL DEFAULT (unixepoch()), updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) ); @@ -55,6 +56,16 @@ function createCtx() { createdAt INTEGER NOT NULL DEFAULT (unixepoch()), updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) ); + + CREATE TABLE voidend_auth_configs ( + id TEXT PRIMARY KEY, + endpointId TEXT NOT NULL REFERENCES voidend_endpoints(id) ON DELETE CASCADE, + isLoginEndpoint INTEGER NOT NULL DEFAULT 0, + requiresAuth INTEGER NOT NULL DEFAULT 0, + tokenExpirySeconds INTEGER NOT NULL DEFAULT 3600, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); `); return { db, headers: new Headers() }; } @@ -156,6 +167,19 @@ describe("endpointRouter", () => { ]); }); + it("includes authConfig for endpoints in getByProject", async () => { + const created = await caller.create({ name: "A", projectId, path: "/a" }); + const [authConfig] = await ctx.db + .insert(schema.auth_configs_table) + .values({ endpointId: created!.id, requiresAuth: true }) + .returning(); + + const endpoints = await caller.getByProject({ projectId }); + expect(endpoints).toHaveLength(1); + expect(endpoints[0]?.authConfig?.id).toBe(authConfig!.id); + expect(endpoints[0]?.authConfig?.requiresAuth).toBe(true); + }); + it("returns empty array when project has no endpoints", async () => { expect(await caller.getByProject({ projectId })).toEqual([]); }); diff --git a/src/server/api/routers/endpoint.ts b/src/server/api/routers/endpoint.ts index 0b9120f..c85b15c 100644 --- a/src/server/api/routers/endpoint.ts +++ b/src/server/api/routers/endpoint.ts @@ -62,10 +62,24 @@ export const endpointRouter = createTRPCRouter({ getByProject: publicProcedure .input(z.object({ projectId: z.string().uuid() })) .query(async ({ ctx, input }) => { - return ctx.db.query.endpoints_table.findMany({ + const endpoints = await ctx.db.query.endpoints_table.findMany({ where: (e, { eq }) => eq(e.projectId, input.projectId), orderBy: (e, { asc }) => [asc(e.path), asc(e.method)], }); + + if (endpoints.length === 0) return []; + + const endpointIds = endpoints.map((e) => e.id); + const authConfigs = await ctx.db.query.auth_configs_table.findMany({ + where: (a, { inArray }) => inArray(a.endpointId, endpointIds), + }); + const configMap = new Map(authConfigs.map((c) => [c.endpointId, c])); + + return endpoints.map((e) => + Object.assign({}, e, { + authConfig: configMap.get(e.id) ?? null, + }), + ); }), getByFolder: publicProcedure @@ -84,7 +98,16 @@ export const endpointRouter = createTRPCRouter({ where: (e, { eq }) => eq(e.id, input.id), }); - return endpoint ?? null; + if (!endpoint) return null; + + const authConfig = await ctx.db.query.auth_configs_table.findFirst({ + where: (a, { eq }) => eq(a.endpointId, endpoint.id), + }); + + return { + ...endpoint, + authConfig: authConfig ?? null, + }; }), update: publicProcedure diff --git a/src/server/api/routers/folder.test.ts b/src/server/api/routers/folder.test.ts index 8849fd5..95e1b2c 100644 --- a/src/server/api/routers/folder.test.ts +++ b/src/server/api/routers/folder.test.ts @@ -22,6 +22,7 @@ function createCtx() { title TEXT NOT NULL, description TEXT, basePath TEXT NOT NULL DEFAULT '/', + secret TEXT NOT NULL, createdAt INTEGER NOT NULL DEFAULT (unixepoch()), updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) ); diff --git a/src/server/api/routers/project.test.ts b/src/server/api/routers/project.test.ts index 35ae828..7938382 100644 --- a/src/server/api/routers/project.test.ts +++ b/src/server/api/routers/project.test.ts @@ -22,6 +22,7 @@ function createCtx() { title TEXT NOT NULL, description TEXT, basePath TEXT NOT NULL DEFAULT '/', + secret TEXT NOT NULL, createdAt INTEGER NOT NULL DEFAULT (unixepoch()), updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) ); @@ -44,6 +45,12 @@ describe("projectRouter", () => { const project = await caller.create({ title: "Test" }); expect(project?.title).toBe("Test"); expect(project?.basePath).toBe("/"); + expect(project?.secret).toBeTruthy(); + }); + + it("creates a project with a custom secret", async () => { + const project = await caller.create({ title: "Custom", secret: "custom-secret-key" }); + expect(project?.secret).toBe("custom-secret-key"); }); it("rejects empty title", async () => { @@ -71,6 +78,7 @@ describe("projectRouter", () => { const created = await caller.create({ title: "Findme" }); const found = await caller.getById({ id: created!.id }); expect(found?.id).toBe(created!.id); + expect(found?.secret).toBe(created!.secret); }); it("returns null when not found", async () => { @@ -86,9 +94,14 @@ describe("projectRouter", () => { describe("update", () => { it("updates provided fields only", async () => { const created = await caller.create({ title: "Old", description: "d" }); - const updated = await caller.update({ id: created!.id, title: "New" }); + const updated = await caller.update({ + id: created!.id, + title: "New", + secret: "updated-secret", + }); expect(updated?.title).toBe("New"); expect(updated?.description).toBe("d"); + expect(updated?.secret).toBe("updated-secret"); }); it("returns undefined when id not found", async () => { diff --git a/src/server/api/routers/project.ts b/src/server/api/routers/project.ts index eef777b..dd94553 100644 --- a/src/server/api/routers/project.ts +++ b/src/server/api/routers/project.ts @@ -11,6 +11,7 @@ export const projectRouter = createTRPCRouter({ title: z.string().min(1), description: z.string().optional(), basePath: z.string().default("/"), + secret: z.string().optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -41,6 +42,7 @@ export const projectRouter = createTRPCRouter({ title: z.string().min(1).optional(), description: z.string().optional(), basePath: z.string().optional(), + secret: z.string().optional(), }), ) .mutation(async ({ ctx, input }) => { diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 72da8e6..fc5b853 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -1,6 +1,7 @@ import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core"; import { index, sqliteTableCreator } from "drizzle-orm/sqlite-core"; import { sql } from "drizzle-orm"; +import crypto from "node:crypto"; export const createTable = sqliteTableCreator((name) => `voidend_${name}`); @@ -14,6 +15,10 @@ export const projects_table = createTable( title: d.text().notNull(), description: d.text(), basePath: d.text().notNull().default("/"), + secret: d + .text() + .notNull() + .$defaultFn(() => crypto.randomBytes(32).toString("hex")), createdAt: d .integer({ mode: "timestamp" }) .notNull() @@ -96,3 +101,30 @@ export const endpoints_table = createTable( index("endpoint_path_idx").on(t.projectId, t.method, t.path), ], ); + +export const auth_configs_table = createTable( + "auth_configs", + (d) => ({ + id: d + .text() + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + endpointId: d + .text() + .notNull() + .references(() => endpoints_table.id, { onDelete: "cascade" }), + isLoginEndpoint: d.integer({ mode: "boolean" }).notNull().default(false), + requiresAuth: d.integer({ mode: "boolean" }).notNull().default(false), + tokenExpirySeconds: d.integer().notNull().default(3600), + createdAt: d + .integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: d + .integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`) + .$onUpdate(() => new Date()), + }), + (t) => [index("auth_config_endpoint_idx").on(t.endpointId)], +); diff --git a/tests/integration/auth-flow.test.ts b/tests/integration/auth-flow.test.ts new file mode 100644 index 0000000..1718b3a --- /dev/null +++ b/tests/integration/auth-flow.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { projectRouter } from "~/server/api/routers/project"; +import { endpointRouter } from "~/server/api/routers/endpoint"; +import { authConfigRouter } from "~/server/api/routers/auth-config"; +import { createRealCtx, setupIsolatedDataDir } from "./test-context"; +import { POST, GET } from "~/app/mock/[projectId]/[...path]/route"; + +vi.mock("~/server/db", () => ({ + db: { + query: { + projects_table: { + findFirst: vi.fn(), + }, + endpoints_table: { + findMany: vi.fn(), + }, + auth_configs_table: { + findFirst: vi.fn(), + }, + }, + }, +})); + +import { db } from "~/server/db"; + +describe("mock auth flow integration", () => { + let ctx: ReturnType; + let isolated: ReturnType; + let projectCaller: ReturnType; + let endpointCaller: ReturnType; + let authConfigCaller: ReturnType; + + beforeEach(() => { + isolated = setupIsolatedDataDir(); + ctx = createRealCtx(); + projectCaller = projectRouter.createCaller(ctx); + endpointCaller = endpointRouter.createCaller(ctx); + authConfigCaller = authConfigRouter.createCaller(ctx); + }); + + afterEach(() => { + isolated.cleanup(); + }); + + it("issues token on login endpoint and authorizes protected endpoint", async () => { + const project = await projectCaller.create({ title: "Auth Project" }); + const loginEndpoint = await endpointCaller.create({ + name: "Login", + projectId: project!.id, + method: "POST", + path: "/login", + responseSchema: { status: "ok" }, + }); + const userEndpoint = await endpointCaller.create({ + name: "User Profile", + projectId: project!.id, + method: "GET", + path: "/user", + responseSchema: { id: "123", name: "Alice" }, + }); + + await authConfigCaller.upsert({ + endpointId: loginEndpoint!.id, + isLoginEndpoint: true, + }); + + await authConfigCaller.upsert({ + endpointId: userEndpoint!.id, + requiresAuth: true, + }); + + vi.mocked(db.query.projects_table.findFirst).mockImplementation((async ({ where }: any) => { + return ctx.db.query.projects_table.findFirst({ where }); + }) as any); + vi.mocked(db.query.endpoints_table.findMany).mockImplementation((async ({ where }: any) => { + return ctx.db.query.endpoints_table.findMany({ where }); + }) as any); + vi.mocked(db.query.auth_configs_table.findFirst).mockImplementation((async ({ + where, + }: any) => { + return ctx.db.query.auth_configs_table.findFirst({ where }); + }) as any); + + const loginReq = new NextRequest(`http://localhost/mock/${project!.id}/login`, { + method: "POST", + }); + const loginRes = await POST(loginReq, { + params: { projectId: project!.id, path: ["login"] }, + }); + expect(loginRes.status).toBe(200); + + const loginData = await loginRes.json(); + expect(loginData).toHaveProperty("token"); + expect(loginData).toHaveProperty("data"); + expect(loginData.data).toEqual({ status: "ok" }); + + const unauthReq = new NextRequest(`http://localhost/mock/${project!.id}/user`, { + method: "GET", + }); + const unauthRes = await GET(unauthReq, { + params: { projectId: project!.id, path: ["user"] }, + }); + expect(unauthRes.status).toBe(401); + const unauthBody = await unauthRes.json(); + expect(unauthBody.error).toBe("Missing token"); + + const invalidReq = new NextRequest(`http://localhost/mock/${project!.id}/user`, { + method: "GET", + headers: { + authorization: "Bearer invalid.jwt.token", + }, + }); + const invalidRes = await GET(invalidReq, { + params: { projectId: project!.id, path: ["user"] }, + }); + expect(invalidRes.status).toBe(401); + const invalidBody = await invalidRes.json(); + expect(invalidBody.error).toBe("Invalid or expired token"); + + const authReq = new NextRequest(`http://localhost/mock/${project!.id}/user`, { + method: "GET", + headers: { + authorization: `Bearer ${loginData.token}`, + }, + }); + const authRes = await GET(authReq, { + params: { projectId: project!.id, path: ["user"] }, + }); + expect(authRes.status).toBe(200); + const authData = await authRes.json(); + expect(authData).toEqual({ id: "123", name: "Alice" }); + }); +}); diff --git a/tests/integration/test-context.ts b/tests/integration/test-context.ts index 639b0fa..6ceef90 100644 --- a/tests/integration/test-context.ts +++ b/tests/integration/test-context.ts @@ -16,6 +16,7 @@ export function createRealCtx() { title TEXT NOT NULL, description TEXT, basePath TEXT NOT NULL DEFAULT '/', + secret TEXT NOT NULL, createdAt INTEGER NOT NULL DEFAULT (unixepoch()), updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) ); @@ -46,6 +47,16 @@ export function createRealCtx() { createdAt INTEGER NOT NULL DEFAULT (unixepoch()), updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) ); + + CREATE TABLE voidend_auth_configs ( + id TEXT PRIMARY KEY, + endpointId TEXT NOT NULL REFERENCES voidend_endpoints(id) ON DELETE CASCADE, + isLoginEndpoint INTEGER NOT NULL DEFAULT 0, + requiresAuth INTEGER NOT NULL DEFAULT 0, + tokenExpirySeconds INTEGER NOT NULL DEFAULT 3600, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); `); return { db, headers: new Headers() }; }