diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..c0a40881 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,18 @@ +#!/bin/sh + +# Get staged files matching Prettier patterns +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ts|tsx|js|jsx|json|md|mdx|css|html)$' || true) + +# If there are no matching files, exit successfully +if [ -z "$STAGED_FILES" ]; then + exit 0 +fi + +# Format staged files with Prettier +echo "$STAGED_FILES" | xargs npx prettier --write || { + echo "Prettier formatting failed. Please run 'npm run format' to fix formatting issues." + exit 1 +} + +# Re-stage formatted files +echo "$STAGED_FILES" | xargs git add diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..93616abb --- /dev/null +++ b/.prettierignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ +**/node_modules/ + +# Build outputs +dist/ +build/ +.output/ +*.tsbuildinfo + +# Generated files +**/routeTree.gen.ts +**/*.gen.ts +**/drizzle/ + +# Logs +*.log + +# Environment files +.env +.env.local + +# Lock files +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Database files +*.db +*.sqlite + +# OS files +.DS_Store + +# IDE +.vscode/ +.idea/ + +# Temporary files +tmp/ +temp/ +*.tmp diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..8daa8555 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6dcaf92..c5c538c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,9 @@ outray/ - Use TypeScript - Follow existing code patterns -- Run `npm run lint` before committing +- Code formatting is enforced with Prettier +- Run `npm run format` to format all files, or `npm run format:check` to check formatting +- Pre-commit hooks will automatically format staged files before commits ## Pull Requests diff --git a/apps/cli/src/toml-config.ts b/apps/cli/src/toml-config.ts index dfffa5bd..f1709ddd 100644 --- a/apps/cli/src/toml-config.ts +++ b/apps/cli/src/toml-config.ts @@ -42,13 +42,10 @@ const globalConfigSchema = Joi.object({ }); const tunnelConfigSchema = Joi.object({ - protocol: Joi.string() - .valid("http", "tcp", "udp") - .required() - .messages({ - "any.only": "Protocol must be one of: http, tcp, or udp", - "any.required": "Protocol is required (must be http, tcp, or udp)", - }), + protocol: Joi.string().valid("http", "tcp", "udp").required().messages({ + "any.only": "Protocol must be one of: http, tcp, or udp", + "any.required": "Protocol is required (must be http, tcp, or udp)", + }), local_port: portSchema.messages({ "number.base": "Local port must be a number", "number.integer": "Local port must be an integer", @@ -121,19 +118,21 @@ export class TomlConfigParser { ); } - const { value: config, error: validationError } = - configSchema.validate(rawConfig, { + const { value: config, error: validationError } = configSchema.validate( + rawConfig, + { abortEarly: false, allowUnknown: false, stripUnknown: false, - }); + }, + ); if (validationError) { const errorMessages = validationError.details.map( (detail: Joi.ValidationErrorItem) => { const pathParts = detail.path; const tunnelName = pathParts[1]; - const fieldName = (pathParts[2] as string); + const fieldName = pathParts[2] as string; let message = detail.message; @@ -142,25 +141,32 @@ export class TomlConfigParser { if (context?.message) { message = context.message; } else { - message = message.replace("contains an invalid value", "has invalid configuration"); + message = message.replace( + "contains an invalid value", + "has invalid configuration", + ); } } - if (fieldName) { - const fieldDisplayName = fieldName.replace(/_/g, " "); - const fullPath = detail.path.join("."); - - if (message.includes(`"${fullPath}"`)) { - message = message.replace(`"${fullPath}"`, fieldDisplayName); - } else if (message.includes(fieldName)) { - message = message.replace(new RegExp(fieldName, "g"), fieldDisplayName); - } + if (fieldName) { + const fieldDisplayName = fieldName.replace(/_/g, " "); + const fullPath = detail.path.join("."); + + if (message.includes(`"${fullPath}"`)) { + message = message.replace(`"${fullPath}"`, fieldDisplayName); + } else if (message.includes(fieldName)) { + message = message.replace( + new RegExp(fieldName, "g"), + fieldDisplayName, + ); } + } - message = message.replace(/^"/, "").replace(/"$/, ""); + message = message.replace(/^"/, "").replace(/"$/, ""); if (tunnelName && typeof tunnelName === "string") { - const capitalizedMessage = message.charAt(0).toUpperCase() + message.slice(1); + const capitalizedMessage = + message.charAt(0).toUpperCase() + message.slice(1); return `Tunnel "${tunnelName}": ${capitalizedMessage}`; } diff --git a/apps/tunnel/src/core/HTTPProxy.ts b/apps/tunnel/src/core/HTTPProxy.ts index c0ee73f8..e43f1abe 100644 --- a/apps/tunnel/src/core/HTTPProxy.ts +++ b/apps/tunnel/src/core/HTTPProxy.ts @@ -157,7 +157,7 @@ export class HTTPProxy { if (metadata.fullCaptureEnabled) { const captureId = generateId("capture"); const maxBodySize = 1024 * 1024; // 1MB limit - + // Prepare request body (truncate if too large) let requestBody: string | null = null; let requestBodySize = bodyBuffer.length; @@ -165,7 +165,9 @@ export class HTTPProxy { if (bodyBuffer.length <= maxBodySize) { requestBody = bodyBuffer.toString("base64"); } else { - requestBody = bodyBuffer.subarray(0, maxBodySize).toString("base64"); + requestBody = bodyBuffer + .subarray(0, maxBodySize) + .toString("base64"); } } @@ -176,7 +178,9 @@ export class HTTPProxy { if (responseBuffer.length <= maxBodySize) { responseBody = responseBuffer.toString("base64"); } else { - responseBody = responseBuffer.subarray(0, maxBodySize).toString("base64"); + responseBody = responseBuffer + .subarray(0, maxBodySize) + .toString("base64"); responseBodySize = responseBuffer.subarray(0, maxBodySize).length; } } diff --git a/apps/tunnel/src/core/PortAllocator.ts b/apps/tunnel/src/core/PortAllocator.ts index 3c2e7676..2356736e 100644 --- a/apps/tunnel/src/core/PortAllocator.ts +++ b/apps/tunnel/src/core/PortAllocator.ts @@ -3,79 +3,78 @@ * Instead of linear search, maintains a set of available ports. */ export class PortAllocator { - private availablePorts: number[] = []; - private usedPorts = new Set(); - private min: number; - private max: number; + private availablePorts: number[] = []; + private usedPorts = new Set(); + private min: number; + private max: number; - constructor(min: number, max: number) { - this.min = min; - this.max = max; - // Initialize with all ports available (lazy - we shuffle on first access) - this.initializePorts(); - } + constructor(min: number, max: number) { + this.min = min; + this.max = max; + // Initialize with all ports available (lazy - we shuffle on first access) + this.initializePorts(); + } - private initializePorts(): void { - // Create array of all ports and shuffle for random distribution - const ports: number[] = []; - for (let p = this.min; p <= this.max; p++) { - ports.push(p); - } - // Fisher-Yates shuffle for random port assignment - for (let i = ports.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [ports[i], ports[j]] = [ports[j], ports[i]]; - } - this.availablePorts = ports; + private initializePorts(): void { + // Create array of all ports and shuffle for random distribution + const ports: number[] = []; + for (let p = this.min; p <= this.max; p++) { + ports.push(p); } + // Fisher-Yates shuffle for random port assignment + for (let i = ports.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [ports[i], ports[j]] = [ports[j], ports[i]]; + } + this.availablePorts = ports; + } - allocate(requestedPort?: number): number | null { - if (requestedPort !== undefined) { - - if (this.usedPorts.has(requestedPort)) { - return null; // Already in use - } - if (!Number.isFinite(requestedPort) || !Number.isInteger(requestedPort)) { - return null; - } - if (requestedPort < this.min || requestedPort > this.max) { - return null; // Out of range - } - - const idx = this.availablePorts.indexOf(requestedPort); - if (idx !== -1) { - this.availablePorts.splice(idx, 1); - } - this.usedPorts.add(requestedPort); - return requestedPort; - } - - while (this.availablePorts.length > 0) { - const port = this.availablePorts.pop()!; - if (!this.usedPorts.has(port)) { - this.usedPorts.add(port); - return port; - } - } + allocate(requestedPort?: number): number | null { + if (requestedPort !== undefined) { + if (this.usedPorts.has(requestedPort)) { + return null; // Already in use + } + if (!Number.isFinite(requestedPort) || !Number.isInteger(requestedPort)) { return null; - } + } + if (requestedPort < this.min || requestedPort > this.max) { + return null; // Out of range + } - release(port: number): void { - if (this.usedPorts.has(port)) { - this.usedPorts.delete(port); - this.availablePorts.push(port); - } + const idx = this.availablePorts.indexOf(requestedPort); + if (idx !== -1) { + this.availablePorts.splice(idx, 1); + } + this.usedPorts.add(requestedPort); + return requestedPort; } - isInUse(port: number): boolean { - return this.usedPorts.has(port); + while (this.availablePorts.length > 0) { + const port = this.availablePorts.pop()!; + if (!this.usedPorts.has(port)) { + this.usedPorts.add(port); + return port; + } } + return null; + } - availableCount(): number { - return this.availablePorts.length; + release(port: number): void { + if (this.usedPorts.has(port)) { + this.usedPorts.delete(port); + this.availablePorts.push(port); } + } - usedCount(): number { - return this.usedPorts.size; - } + isInUse(port: number): boolean { + return this.usedPorts.has(port); + } + + availableCount(): number { + return this.availablePorts.length; + } + + usedCount(): number { + return this.usedPorts.size; + } } diff --git a/apps/tunnel/src/lib/tigerdata.ts b/apps/tunnel/src/lib/tigerdata.ts index 4494a337..d1361eb2 100644 --- a/apps/tunnel/src/lib/tigerdata.ts +++ b/apps/tunnel/src/lib/tigerdata.ts @@ -167,7 +167,7 @@ class RequestCaptureLogger { captures.forEach((capture, i) => { const offset = i * 11; placeholders.push( - `($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6}, $${offset + 7}, $${offset + 8}, $${offset + 9}, $${offset + 10}, $${offset + 11})` + `($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6}, $${offset + 7}, $${offset + 8}, $${offset + 9}, $${offset + 10}, $${offset + 11})`, ); values.push( capture.id, @@ -180,7 +180,7 @@ class RequestCaptureLogger { capture.request_body_size, JSON.stringify(capture.response_headers), capture.response_body, - capture.response_body_size + capture.response_body_size, ); }); diff --git a/apps/tunnel/src/server.ts b/apps/tunnel/src/server.ts index 997dc41d..5b4cd5d5 100644 --- a/apps/tunnel/src/server.ts +++ b/apps/tunnel/src/server.ts @@ -8,10 +8,7 @@ import { TCPProxy } from "./core/TCPProxy"; import { UDPProxy } from "./core/UDPProxy"; import { LogManager } from "./core/LogManager"; import { config } from "./config"; -import { - checkTimescaleDBConnection, - shutdownLoggers, -} from "./lib/tigerdata"; +import { checkTimescaleDBConnection, shutdownLoggers } from "./lib/tigerdata"; const redis = new Redis(config.redisUrl, { lazyConnect: true, @@ -94,7 +91,7 @@ async function validateDashboardToken(token: string): Promise<{ method: "POST", headers: { "Content-Type": "application/json", - "Authorization": `Bearer ${internalApiSecret}`, + Authorization: `Bearer ${internalApiSecret}`, }, body: JSON.stringify({ token }), }); @@ -183,10 +180,10 @@ const shutdown = async () => { wsHandler.shutdown(); await router.shutdown(); await redis.quit(); - + // Flush buffered logs and close database connection await shutdownLoggers(); - + httpServer.close(() => process.exit(0)); }; diff --git a/apps/web/.source/browser.ts b/apps/web/.source/browser.ts index 80283264..f4f7a8c7 100644 --- a/apps/web/.source/browser.ts +++ b/apps/web/.source/browser.ts @@ -1,19 +1,24 @@ // @ts-nocheck /// -import { browser } from 'fumadocs-mdx/runtime/browser'; -import type * as Config from '../source.config'; +import { browser } from "fumadocs-mdx/runtime/browser"; +import type * as Config from "../source.config"; -const create = browser(); +>(); const browserCollections = { - docs: create.doc("docs", import.meta.glob(["./**/*.{mdx,md}"], { - "base": "./../content/docs", - "query": { - "collection": "docs" - }, - "eager": false - })), + docs: create.doc( + "docs", + import.meta.glob(["./**/*.{mdx,md}"], { + base: "./../content/docs", + query: { + collection: "docs", + }, + eager: false, + }), + ), }; -export default browserCollections; \ No newline at end of file +export default browserCollections; diff --git a/apps/web/.source/dynamic.ts b/apps/web/.source/dynamic.ts index 400d0f63..a3bded56 100644 --- a/apps/web/.source/dynamic.ts +++ b/apps/web/.source/dynamic.ts @@ -1,9 +1,15 @@ // @ts-nocheck /// -import { dynamic } from 'fumadocs-mdx/runtime/dynamic'; -import * as Config from '../source.config'; +import { dynamic } from "fumadocs-mdx/runtime/dynamic"; +import * as Config from "../source.config"; -const create = await dynamic(Config, {"configPath":"source.config.ts","environment":"vite","outDir":".source"}, {"doc":{"passthroughs":["extractedReferences"]}}); \ No newline at end of file +>( + Config, + { configPath: "source.config.ts", environment: "vite", outDir: ".source" }, + { doc: { passthroughs: ["extractedReferences"] } }, +); diff --git a/apps/web/.source/server.ts b/apps/web/.source/server.ts index 6f3ecdd7..54db990d 100644 --- a/apps/web/.source/server.ts +++ b/apps/web/.source/server.ts @@ -1,24 +1,31 @@ // @ts-nocheck /// -import { server } from 'fumadocs-mdx/runtime/server'; -import type * as Config from '../source.config'; +import { server } from "fumadocs-mdx/runtime/server"; +import type * as Config from "../source.config"; -const create = server({"doc":{"passthroughs":["extractedReferences"]}}); +>({ doc: { passthroughs: ["extractedReferences"] } }); -export const docs = await create.docs("docs", "content/docs", import.meta.glob(["./**/*.{json,yaml}"], { - "base": "./../content/docs", - "query": { - "collection": "docs" - }, - "import": "default", - "eager": true -}), import.meta.glob(["./**/*.{mdx,md}"], { - "base": "./../content/docs", - "query": { - "collection": "docs" - }, - "eager": true -})); \ No newline at end of file +export const docs = await create.docs( + "docs", + "content/docs", + import.meta.glob(["./**/*.{json,yaml}"], { + base: "./../content/docs", + query: { + collection: "docs", + }, + import: "default", + eager: true, + }), + import.meta.glob(["./**/*.{mdx,md}"], { + base: "./../content/docs", + query: { + collection: "docs", + }, + eager: true, + }), +); diff --git a/apps/web/README.md b/apps/web/README.md index d2e77611..8c2d7602 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -17,9 +17,9 @@ If you are developing a production application, we recommend updating the config ```js export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ // Other configs... @@ -34,40 +34,40 @@ export default defineConfig([ ], languageOptions: { parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], + project: ["./tsconfig.node.json", "./tsconfig.app.json"], tsconfigRootDir: import.meta.dirname, }, // other options... }, }, -]) +]); ``` You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: ```js // eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +import reactX from "eslint-plugin-react-x"; +import reactDom from "eslint-plugin-react-dom"; export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ // Other configs... // Enable lint rules for React - reactX.configs['recommended-typescript'], + reactX.configs["recommended-typescript"], // Enable lint rules for React DOM reactDom.configs.recommended, ], languageOptions: { parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], + project: ["./tsconfig.node.json", "./tsconfig.app.json"], tsconfigRootDir: import.meta.dirname, }, // other options... }, }, -]) +]); ``` diff --git a/apps/web/src/components/admin/admin-skeleton.tsx b/apps/web/src/components/admin/admin-skeleton.tsx index 5cae16f9..9ca8d23d 100644 --- a/apps/web/src/components/admin/admin-skeleton.tsx +++ b/apps/web/src/components/admin/admin-skeleton.tsx @@ -1,9 +1,5 @@ export function SkeletonBox({ className = "" }: { className?: string }) { - return ( -
- ); + return
; } export function StatsCardSkeleton() { diff --git a/apps/web/src/components/landing/github-button.tsx b/apps/web/src/components/landing/github-button.tsx index faeb1f74..6219a059 100644 --- a/apps/web/src/components/landing/github-button.tsx +++ b/apps/web/src/components/landing/github-button.tsx @@ -13,7 +13,7 @@ export const GitHubButton = ({ size = "md" }: GitHubButtonProps) => { const fetchStars = async () => { try { const res = await fetch( - "https://api.github.com/repos/outray-tunnel/outray" + "https://api.github.com/repos/outray-tunnel/outray", ); if (res.ok) { const data = await res.json(); diff --git a/apps/web/src/components/requests/full-capture-disabled-content.tsx b/apps/web/src/components/requests/full-capture-disabled-content.tsx index 44ae46c8..59130fde 100644 --- a/apps/web/src/components/requests/full-capture-disabled-content.tsx +++ b/apps/web/src/components/requests/full-capture-disabled-content.tsx @@ -6,7 +6,10 @@ interface FullCaptureDisabledContentProps { orgSlug: string; } -export function FullCaptureDisabledContent({ request, orgSlug }: FullCaptureDisabledContentProps) { +export function FullCaptureDisabledContent({ + request, + orgSlug, +}: FullCaptureDisabledContentProps) { return (
{/* Info banner */} @@ -16,9 +19,12 @@ export function FullCaptureDisabledContent({ request, orgSlug }: FullCaptureDisa i
-

Full capture is disabled

+

+ Full capture is disabled +

- Only basic request metadata is available. Enable full capture to inspect headers, body, and replay requests. + Only basic request metadata is available. Enable full capture to + inspect headers, body, and replay requests.

@@ -57,7 +64,9 @@ export function FullCaptureDisabledContent({ request, orgSlug }: FullCaptureDisa
Status - {request.status_code} + + {request.status_code} +
Client IP @@ -65,11 +74,15 @@ export function FullCaptureDisabledContent({ request, orgSlug }: FullCaptureDisa
Duration - {request.request_duration_ms}ms + + {request.request_duration_ms}ms +
Size - {formatBytes(request.bytes_in + request.bytes_out)} + + {formatBytes(request.bytes_in + request.bytes_out)} +
@@ -96,7 +109,11 @@ export function FullCaptureDisabledContent({ request, orgSlug }: FullCaptureDisa
{[...Array(4)].map((_, i) => ( -
+
))}
diff --git a/apps/web/src/components/requests/json-viewer.tsx b/apps/web/src/components/requests/json-viewer.tsx index a335c880..aba9f329 100644 --- a/apps/web/src/components/requests/json-viewer.tsx +++ b/apps/web/src/components/requests/json-viewer.tsx @@ -6,7 +6,9 @@ interface JsonViewerProps { initialExpanded?: boolean; } -const ExpandAllContext = createContext<{ expandAll: boolean; version: number }>({ expandAll: false, version: 0 }); +const ExpandAllContext = createContext<{ expandAll: boolean; version: number }>( + { expandAll: false, version: 0 }, +); function JsonValue({ value, depth = 0 }: { value: unknown; depth?: number }) { const { expandAll, version } = useContext(ExpandAllContext); @@ -86,7 +88,9 @@ function JsonValue({ value, depth = 0 }: { value: unknown; depth?: number }) { {index}: - {index < value.length - 1 && ,} + {index < value.length - 1 && ( + , + )}
))} @@ -136,7 +140,9 @@ function JsonValue({ value, depth = 0 }: { value: unknown; depth?: number }) { : - {index < entries.length - 1 && ,} + {index < entries.length - 1 && ( + , + )} ))} @@ -155,7 +161,7 @@ export function JsonViewer({ data }: JsonViewerProps) { const toggleExpandAll = () => { setExpandAll(!expandAll); - setVersion(v => v + 1); + setVersion((v) => v + 1); }; return ( @@ -178,7 +184,11 @@ export function JsonViewer({ data }: JsonViewerProps) { ); } -export function formatBody(body: string | null): { isJson: boolean; parsed: unknown; formatted: string } { +export function formatBody(body: string | null): { + isJson: boolean; + parsed: unknown; + formatted: string; +} { if (!body) { return { isJson: false, parsed: null, formatted: "" }; } diff --git a/apps/web/src/components/requests/replay-modal.tsx b/apps/web/src/components/requests/replay-modal.tsx index 500d336f..09d43080 100644 --- a/apps/web/src/components/requests/replay-modal.tsx +++ b/apps/web/src/components/requests/replay-modal.tsx @@ -1,5 +1,19 @@ import { useState, useEffect, useRef } from "react"; -import { X, Play, Loader2, Clock, ArrowRight, CheckCircle2, XCircle, AlertCircle, Plus, Trash2, Edit3, FileText, ListTree } from "lucide-react"; +import { + X, + Play, + Loader2, + Clock, + ArrowRight, + CheckCircle2, + XCircle, + AlertCircle, + Plus, + Trash2, + Edit3, + FileText, + ListTree, +} from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import type { TunnelEvent, RequestCapture } from "./types"; import { JsonViewer, formatBody } from "./json-viewer"; @@ -22,19 +36,27 @@ interface ReplayModalProps { orgSlug: string; } -export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: ReplayModalProps) { +export function ReplayModal({ + isOpen, + onClose, + request, + capture, + orgSlug, +}: ReplayModalProps) { const [replaying, setReplaying] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState("headers"); - + // Editable request state const [url, setUrl] = useState(""); const [method, setMethod] = useState(request.method); - const [headers, setHeaders] = useState>([]); + const [headers, setHeaders] = useState< + Array<{ key: string; value: string; enabled: boolean }> + >([]); const [body, setBody] = useState(""); const [isEditing, setIsEditing] = useState(false); - + // Store original values for reset const [originalState, setOriginalState] = useState<{ url: string; @@ -43,7 +65,7 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl body: string; } | null>(null); - const hasBody = !['GET', 'HEAD'].includes(method); + const hasBody = !["GET", "HEAD"].includes(method); // Ref for scrolling to result const resultRef = useRef(null); @@ -51,17 +73,22 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl // Initialize editable state from capture useEffect(() => { if (isOpen && capture) { - const protocol = request.host.includes('localhost') ? 'http' : 'https'; + const protocol = request.host.includes("localhost") ? "http" : "https"; const initialUrl = `${protocol}://${request.host}${request.path}`; setUrl(initialUrl); setMethod(request.method); - - const excludeHeaders = ['host', 'content-length', 'transfer-encoding', 'connection']; + + const excludeHeaders = [ + "host", + "content-length", + "transfer-encoding", + "connection", + ]; const headerList = Object.entries(capture.request.headers) .filter(([key]) => !excludeHeaders.includes(key.toLowerCase())) .map(([key, value]) => ({ key, - value: Array.isArray(value) ? value.join(', ') : value, + value: Array.isArray(value) ? value.join(", ") : value, enabled: true, })); setHeaders(headerList); @@ -71,7 +98,7 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl setError(null); setIsEditing(false); setActiveTab("headers"); - + // Store original state for reset setOriginalState({ url: initialUrl, @@ -86,7 +113,7 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl if (originalState) { setUrl(originalState.url); setMethod(originalState.method); - setHeaders(originalState.headers.map(h => ({ ...h }))); + setHeaders(originalState.headers.map((h) => ({ ...h }))); setBody(originalState.body); } setIsEditing(false); @@ -99,23 +126,30 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl try { const requestHeaders: Record = {}; - headers.filter(h => h.enabled && h.key).forEach(h => { - requestHeaders[h.key] = h.value; - }); + headers + .filter((h) => h.enabled && h.key) + .forEach((h) => { + requestHeaders[h.key] = h.value; + }); // Use the proxy endpoint to avoid CORS issues - const response = await fetch(`/api/${encodeURIComponent(orgSlug)}/requests/replay`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetch( + `/api/${encodeURIComponent(orgSlug)}/requests/replay`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url, + method, + headers: requestHeaders, + requestBody: ["GET", "HEAD"].includes(method) + ? undefined + : body || undefined, + }), }, - body: JSON.stringify({ - url, - method, - headers: requestHeaders, - requestBody: ['GET', 'HEAD'].includes(method) ? undefined : body || undefined, - }), - }); + ); const data = await response.json(); @@ -133,10 +167,13 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl // Scroll to result after a short delay to let it render setTimeout(() => { - resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + resultRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); }, 100); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to replay request'); + setError(err instanceof Error ? err.message : "Failed to replay request"); } finally { setReplaying(false); } @@ -150,7 +187,11 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl setHeaders(headers.filter((_, i) => i !== index)); }; - const updateHeader = (index: number, field: "key" | "value" | "enabled", value: string | boolean) => { + const updateHeader = ( + index: number, + field: "key" | "value" | "enabled", + value: string | boolean, + ) => { const newHeaders = [...headers]; newHeaders[index] = { ...newHeaders[index], [field]: value }; setHeaders(newHeaders); @@ -158,7 +199,8 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl const getStatusColor = (status: number) => { if (status >= 500) return "text-red-400 bg-red-500/10 border-red-500/20"; - if (status >= 400) return "text-orange-400 bg-orange-500/10 border-orange-500/20"; + if (status >= 400) + return "text-orange-400 bg-orange-500/10 border-orange-500/20"; if (status >= 300) return "text-blue-400 bg-blue-500/10 border-blue-500/20"; return "text-green-400 bg-green-500/10 border-green-500/20"; }; @@ -166,8 +208,18 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl const bodyInfo = result?.body ? formatBody(result.body) : null; const tabs = [ - { id: "headers" as RequestTab, label: "Headers", icon: ListTree, count: headers.filter(h => h.enabled).length }, - { id: "body" as RequestTab, label: "Body", icon: FileText, count: body ? 1 : 0 }, + { + id: "headers" as RequestTab, + label: "Headers", + icon: ListTree, + count: headers.filter((h) => h.enabled).length, + }, + { + id: "body" as RequestTab, + label: "Body", + icon: FileText, + count: body ? 1 : 0, + }, ]; if (!isOpen) return null; @@ -195,7 +247,9 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl

Replay Request

-

Modify and resend the request

+

+ Modify and resend the request +

@@ -241,8 +295,18 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl onChange={(e) => setMethod(e.target.value)} className="bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white font-mono focus:outline-none focus:border-accent/50" > - {['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'].map(m => ( - + {[ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + ].map((m) => ( + ))} ) : ( @@ -284,9 +348,11 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl {label} {count > 0 && ( - + {count} )} @@ -323,12 +389,17 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl
) : ( headers.map((header, index) => ( -
+
{isEditing && ( updateHeader(index, "enabled", e.target.checked)} + onChange={(e) => + updateHeader(index, "enabled", e.target.checked) + } className="rounded border-white/20 bg-white/5 text-accent focus:ring-accent/50" /> )} @@ -337,14 +408,18 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl updateHeader(index, "key", e.target.value)} + onChange={(e) => + updateHeader(index, "key", e.target.value) + } placeholder="Header name" className="w-1/3 bg-white/5 border border-white/10 rounded px-2 py-1.5 text-sm text-accent font-mono focus:outline-none focus:border-accent/50" /> updateHeader(index, "value", e.target.value)} + onChange={(e) => + updateHeader(index, "value", e.target.value) + } placeholder="Value" className="flex-1 bg-white/5 border border-white/10 rounded px-2 py-1.5 text-sm text-gray-300 font-mono focus:outline-none focus:border-accent/50" /> @@ -356,9 +431,13 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl ) : ( -
+
{header.key}: - {header.value} + + {header.value} +
)}
@@ -386,7 +465,9 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl {bodyFormatted.isJson ? ( ) : ( -
{body}
+
+                          {body}
+                        
)}
); @@ -419,22 +500,34 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl {/* Response summary */}
-

Original

+

+ Original +

- + {request.status_code} - {request.request_duration_ms}ms + + {request.request_duration_ms}ms +
-

Replay

+

+ Replay +

- + {result.status} - {result.duration}ms + + {result.duration}ms + {result.status === request.status_code ? ( ) : ( @@ -450,9 +543,12 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl
-

Status Code Changed

+

+ Status Code Changed +

- Original returned {request.status_code}, replay returned {result.status} + Original returned {request.status_code}, replay returned{" "} + {result.status}

@@ -462,7 +558,9 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl {/* Response Headers */}
- Response Headers + + Response Headers +
{Object.entries(result.headers).map(([key, value]) => ( @@ -478,16 +576,22 @@ export function ReplayModal({ isOpen, onClose, request, capture, orgSlug }: Repl {result.body && (
- Response Body + + Response Body + {bodyInfo?.isJson && ( - JSON + + JSON + )}
{bodyInfo?.isJson ? ( ) : ( -
{result.body}
+
+                        {result.body}
+                      
)}
diff --git a/apps/web/src/components/requests/request-inspector-drawer.tsx b/apps/web/src/components/requests/request-inspector-drawer.tsx index 4c2f87cc..b9276aca 100644 --- a/apps/web/src/components/requests/request-inspector-drawer.tsx +++ b/apps/web/src/components/requests/request-inspector-drawer.tsx @@ -1,5 +1,12 @@ import { useState } from "react"; -import { X, Copy, Play, ArrowDownToLine, ArrowUpFromLine, Check } from "lucide-react"; +import { + X, + Copy, + Play, + ArrowDownToLine, + ArrowUpFromLine, + Check, +} from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import type { TunnelEvent, InspectorTab } from "./types"; import { generateCurl } from "./utils"; @@ -43,7 +50,10 @@ function SkeletonLoader() { {[...Array(6)].map((_, i) => (
-
+
))}
@@ -57,7 +67,11 @@ function SkeletonLoader() {
{[...Array(4)].map((_, i) => ( -
+
))}
@@ -81,7 +95,7 @@ export function RequestInspectorDrawer({ const [activeTab, setActiveTab] = useState("request"); const [copiedField, setCopiedField] = useState(null); const [showReplayModal, setShowReplayModal] = useState(false); - + const { capture, loading, error } = useRequestCapture(orgSlug, request); const copyToClipboard = async (text: string, field: string) => { @@ -104,28 +118,36 @@ export function RequestInspectorDrawer({ }; // Create request details from real data or fallback to basic info - const requestDetails = capture ? { - headers: capture.request.headers, - queryParams: getQueryParams(request.path), - body: capture.request.body, - } : { - headers: { - Host: request.host, - "User-Agent": request.user_agent, - "X-Forwarded-For": request.client_ip, - }, - queryParams: getQueryParams(request.path), - body: null, - }; + const requestDetails = capture + ? { + headers: capture.request.headers, + queryParams: getQueryParams(request.path), + body: capture.request.body, + } + : { + headers: { + Host: request.host, + "User-Agent": request.user_agent, + "X-Forwarded-For": request.client_ip, + }, + queryParams: getQueryParams(request.path), + body: null, + }; - const responseDetails = capture ? { - headers: capture.response.headers, - body: capture.response.body, - } : null; + const responseDetails = capture + ? { + headers: capture.response.headers, + body: capture.response.body, + } + : null; const tabs = [ { id: "request" as InspectorTab, label: "Request", icon: ArrowUpFromLine }, - { id: "response" as InspectorTab, label: "Response", icon: ArrowDownToLine }, + { + id: "response" as InspectorTab, + label: "Response", + icon: ArrowDownToLine, + }, ]; return ( @@ -160,8 +182,13 @@ export function RequestInspectorDrawer({ > {request.status_code}
- {request.method} - + + {request.method} + + {request.path}
@@ -192,10 +219,16 @@ export function RequestInspectorDrawer({ Replay Request @@ -233,7 +266,10 @@ export function RequestInspectorDrawer({ {/* Content */}
{!fullCaptureEnabled ? ( - + ) : loading ? ( ) : error ? ( @@ -274,9 +310,12 @@ export function RequestInspectorDrawer({ ) : (
-

No detailed request data available.

+

+ No detailed request data available. +

- This request may have occurred before full capture was enabled. + This request may have occurred before full capture was + enabled.

@@ -288,7 +327,9 @@ export function RequestInspectorDrawer({
Tunnel ID -

{request.tunnel_id}

+

+ {request.tunnel_id} +

Timestamp diff --git a/apps/web/src/components/requests/request-tab-content.tsx b/apps/web/src/components/requests/request-tab-content.tsx index b45d5de8..83ddbdaf 100644 --- a/apps/web/src/components/requests/request-tab-content.tsx +++ b/apps/web/src/components/requests/request-tab-content.tsx @@ -9,9 +9,14 @@ interface RequestTabContentProps { onCopy: (text: string, field: string) => void; } -export function RequestTabContent({ request, details, copiedField, onCopy }: RequestTabContentProps) { +export function RequestTabContent({ + request, + details, + copiedField, + onCopy, +}: RequestTabContentProps) { const formatHeaderValue = (value: string | string[]): string => { - return Array.isArray(value) ? value.join(', ') : value; + return Array.isArray(value) ? value.join(", ") : value; }; const bodyInfo = formatBody(details.body); @@ -27,7 +32,9 @@ export function RequestTabContent({ request, details, copiedField, onCopy }: Req
URL - {request.host.includes('localhost') ? 'http' : 'https'}://{request.host}{request.path} + {request.host.includes("localhost") ? "http" : "https"}:// + {request.host} + {request.path}
@@ -46,17 +53,25 @@ export function RequestTabContent({ request, details, copiedField, onCopy }: Req
Headers
{Object.entries(details.headers).map(([key, value]) => (
{key}: - {formatHeaderValue(value)} + + {formatHeaderValue(value)} +
))}
@@ -66,7 +81,9 @@ export function RequestTabContent({ request, details, copiedField, onCopy }: Req {Object.keys(details.queryParams).length > 0 && (
- Query Parameters + + Query Parameters +
{Object.entries(details.queryParams).map(([key, value]) => ( @@ -86,21 +103,34 @@ export function RequestTabContent({ request, details, copiedField, onCopy }: Req
Body {bodyInfo.isJson && ( - JSON + + JSON + )}
{bodyInfo.isJson ? ( ) : ( -
{details.body}
+
+                {details.body}
+              
)}
diff --git a/apps/web/src/components/requests/response-tab-content.tsx b/apps/web/src/components/requests/response-tab-content.tsx index ec456b05..ed4008ee 100644 --- a/apps/web/src/components/requests/response-tab-content.tsx +++ b/apps/web/src/components/requests/response-tab-content.tsx @@ -8,9 +8,13 @@ interface ResponseTabContentProps { onCopy: (text: string, field: string) => void; } -export function ResponseTabContent({ details, copiedField, onCopy }: ResponseTabContentProps) { +export function ResponseTabContent({ + details, + copiedField, + onCopy, +}: ResponseTabContentProps) { const formatHeaderValue = (value: string | string[]): string => { - return Array.isArray(value) ? value.join(', ') : value; + return Array.isArray(value) ? value.join(", ") : value; }; const bodyInfo = formatBody(details.body); @@ -22,17 +26,25 @@ export function ResponseTabContent({ details, copiedField, onCopy }: ResponseTab
Headers
{Object.entries(details.headers).map(([key, value]) => (
{key}: - {formatHeaderValue(value)} + + {formatHeaderValue(value)} +
))}
@@ -45,21 +57,34 @@ export function ResponseTabContent({ details, copiedField, onCopy }: ResponseTab
Body {bodyInfo.isJson && ( - JSON + + JSON + )}
{bodyInfo.isJson ? ( ) : ( -
{details.body}
+
+                {details.body}
+              
)}
diff --git a/apps/web/src/components/requests/use-request-capture.ts b/apps/web/src/components/requests/use-request-capture.ts index 49e16834..6de4e320 100644 --- a/apps/web/src/components/requests/use-request-capture.ts +++ b/apps/web/src/components/requests/use-request-capture.ts @@ -1,7 +1,10 @@ import { useState, useEffect } from "react"; import type { RequestCapture, TunnelEvent } from "./types"; -export function useRequestCapture(orgSlug: string, request: TunnelEvent | null) { +export function useRequestCapture( + orgSlug: string, + request: TunnelEvent | null, +) { const [capture, setCapture] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -16,19 +19,19 @@ export function useRequestCapture(orgSlug: string, request: TunnelEvent | null) const fetchCapture = async () => { setLoading(true); setError(null); - + try { const response = await fetch(`/api/${orgSlug}/requests/capture`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ tunnelId: request.tunnel_id, timestamp: request.timestamp, }), }); - + if (!response.ok) { if (response.status === 404) { setError("Request capture not found"); @@ -52,4 +55,4 @@ export function useRequestCapture(orgSlug: string, request: TunnelEvent | null) }, [orgSlug, request?.tunnel_id, request?.timestamp]); return { capture, loading, error }; -} \ No newline at end of file +} diff --git a/apps/web/src/components/requests/utils.ts b/apps/web/src/components/requests/utils.ts index f7107167..5b335e40 100644 --- a/apps/web/src/components/requests/utils.ts +++ b/apps/web/src/components/requests/utils.ts @@ -12,13 +12,16 @@ export function getMockRequestDetails(req: TunnelEvent): RequestDetails { "X-Forwarded-For": req.client_ip, "X-Request-ID": `req_${req.tunnel_id.slice(0, 8)}`, }, - queryParams: - req.path.includes("?") - ? Object.fromEntries(new URLSearchParams(req.path.split("?")[1])) - : {}, + queryParams: req.path.includes("?") + ? Object.fromEntries(new URLSearchParams(req.path.split("?")[1])) + : {}, body: req.method !== "GET" && req.method !== "HEAD" - ? JSON.stringify({ example: "request body", timestamp: req.timestamp }, null, 2) + ? JSON.stringify( + { example: "request body", timestamp: req.timestamp }, + null, + 2, + ) : null, }; } @@ -36,11 +39,12 @@ export function getMockResponseDetails(req: TunnelEvent): ResponseDetails { body: JSON.stringify( { success: req.status_code < 400, - data: req.status_code < 400 ? { id: 1, message: "Sample response" } : null, + data: + req.status_code < 400 ? { id: 1, message: "Sample response" } : null, error: req.status_code >= 400 ? "An error occurred" : null, }, null, - 2 + 2, ), }; } @@ -54,7 +58,10 @@ export function formatBytes(bytes: number, decimals = 0) { return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; } -export function generateCurl(req: TunnelEvent, requestDetails?: RequestDetails): string { +export function generateCurl( + req: TunnelEvent, + requestDetails?: RequestDetails, +): string { // Use real request details if available, otherwise fall back to basic info const details = requestDetails || { headers: { @@ -62,26 +69,26 @@ export function generateCurl(req: TunnelEvent, requestDetails?: RequestDetails): "User-Agent": req.user_agent, "X-Forwarded-For": req.client_ip, }, - queryParams: req.path.includes("?") + queryParams: req.path.includes("?") ? Object.fromEntries(new URLSearchParams(req.path.split("?")[1])) : {}, body: null, }; - const protocol = req.host.includes('localhost') ? 'http' : 'https'; + const protocol = req.host.includes("localhost") ? "http" : "https"; let curl = `curl -X ${req.method} '${protocol}://${req.host}${req.path}'`; - + // Add headers Object.entries(details.headers).forEach(([key, value]) => { // Handle both string and string[] values - const headerValue = Array.isArray(value) ? value.join(', ') : value; + const headerValue = Array.isArray(value) ? value.join(", ") : value; curl += ` \\\n -H '${key}: ${headerValue}'`; }); - + // Add body if present if (details.body) { curl += ` \\\n -d '${details.body.replace(/'/g, "'\\''")}'`; } - + return curl; } diff --git a/apps/web/src/components/sidebar/organization-dropdown.tsx b/apps/web/src/components/sidebar/organization-dropdown.tsx index 30bdf4f6..5123ec9d 100644 --- a/apps/web/src/components/sidebar/organization-dropdown.tsx +++ b/apps/web/src/components/sidebar/organization-dropdown.tsx @@ -47,7 +47,10 @@ export function OrganizationDropdown({ }, [setIsOrgDropdownOpen]); return ( -
+
diff --git a/apps/web/src/routes/admin/organizations.index.tsx b/apps/web/src/routes/admin/organizations.index.tsx index 09cd7a00..92c64e0b 100644 --- a/apps/web/src/routes/admin/organizations.index.tsx +++ b/apps/web/src/routes/admin/organizations.index.tsx @@ -3,7 +3,10 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Search, Building2, Users, Network } from "lucide-react"; import { appClient } from "@/lib/app-client"; -import { AdminDataTable, type Column } from "@/components/admin/admin-data-table"; +import { + AdminDataTable, + type Column, +} from "@/components/admin/admin-data-table"; import { UsersSkeleton } from "@/components/admin/admin-skeleton"; import { useAdminStore } from "@/lib/admin-store"; @@ -79,18 +82,16 @@ function AdminOrganizationsPage() { className="flex items-center gap-3 hover:opacity-80 transition-opacity" > {org.logo ? ( - {org.name} + {org.name} ) : (
)}
-
{org.name}
+
+ {org.name} +
/{org.slug}
@@ -124,8 +125,17 @@ function AdminOrganizationsPage() { header: "Active Tunnels", render: (org) => (
- 0 ? "text-green-400" : "text-gray-500"} /> - 0 ? "text-green-400" : "text-gray-400"}> + 0 ? "text-green-400" : "text-gray-500" + } + /> + 0 ? "text-green-400" : "text-gray-400" + } + > {org.activeTunnels}
diff --git a/apps/web/src/routes/admin/subscriptions.tsx b/apps/web/src/routes/admin/subscriptions.tsx index 47644140..edcd59ff 100644 --- a/apps/web/src/routes/admin/subscriptions.tsx +++ b/apps/web/src/routes/admin/subscriptions.tsx @@ -1,9 +1,18 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { CreditCard, Building2, Calendar, DollarSign, TrendingUp } from "lucide-react"; +import { + CreditCard, + Building2, + Calendar, + DollarSign, + TrendingUp, +} from "lucide-react"; import { appClient } from "@/lib/app-client"; -import { AdminDataTable, type Column } from "@/components/admin/admin-data-table"; +import { + AdminDataTable, + type Column, +} from "@/components/admin/admin-data-table"; import { AdminStatsCard } from "@/components/admin/admin-stats-card"; import { SubscriptionsSkeleton } from "@/components/admin/admin-skeleton"; import { useAdminStore } from "@/lib/admin-store"; @@ -47,7 +56,10 @@ function AdminSubscriptionsPage() { const { data, isLoading, isFetching } = useQuery({ queryKey: ["admin", "subscriptions", page, planFilter], queryFn: async () => { - const res = await appClient.admin.subscriptions(token!, { page, plan: planFilter }); + const res = await appClient.admin.subscriptions(token!, { + page, + plan: planFilter, + }); if ("error" in res) throw new Error(res.error); return res; }, @@ -82,7 +94,9 @@ function AdminSubscriptionsPage() {
-
{sub.orgName || "Unknown"}
+
+ {sub.orgName || "Unknown"} +
/{sub.orgSlug || "-"}
@@ -193,7 +207,9 @@ function AdminSubscriptionsPage() { : "text-gray-500 hover:text-white hover:bg-white/5" }`} > - {plan === "" ? "All" : plan.charAt(0).toUpperCase() + plan.slice(1)} + {plan === "" + ? "All" + : plan.charAt(0).toUpperCase() + plan.slice(1)} ))}
diff --git a/apps/web/src/routes/admin/tunnels.tsx b/apps/web/src/routes/admin/tunnels.tsx index e35ab4d7..36b0a210 100644 --- a/apps/web/src/routes/admin/tunnels.tsx +++ b/apps/web/src/routes/admin/tunnels.tsx @@ -1,9 +1,21 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Search, Network, Globe, Building2, User, Clock, Copy, Check } from "lucide-react"; +import { + Search, + Network, + Globe, + Building2, + User, + Clock, + Copy, + Check, +} from "lucide-react"; import { appClient } from "@/lib/app-client"; -import { AdminDataTable, type Column } from "@/components/admin/admin-data-table"; +import { + AdminDataTable, + type Column, +} from "@/components/admin/admin-data-table"; import { AdminStatsCard } from "@/components/admin/admin-stats-card"; import { TunnelsSkeleton } from "@/components/admin/admin-skeleton"; import { useAdminStore } from "@/lib/admin-store"; @@ -98,32 +110,48 @@ function AdminTunnelsPage() { key: "url", header: "Tunnel", render: (tunnel) => { - const hasProtocol = tunnel.url.startsWith("http://") || tunnel.url.startsWith("https://"); + const hasProtocol = + tunnel.url.startsWith("http://") || tunnel.url.startsWith("https://"); const fullUrl = hasProtocol ? tunnel.url : `https://${tunnel.url}`; const isHttp = tunnel.protocol === "http"; - + return (
-
+
{isHttp ? ( - + {tunnel.url} ) : ( {tunnel.url} )}
- {tunnel.name &&
{tunnel.name}
} + {tunnel.name && ( +
{tunnel.name}
+ )}
); @@ -133,8 +161,11 @@ function AdminTunnelsPage() { key: "protocol", header: "Protocol", render: (tunnel) => ( - - {tunnel.protocol}{tunnel.remotePort && `:${tunnel.remotePort}`} + + {tunnel.protocol} + {tunnel.remotePort && `:${tunnel.remotePort}`} ), }, @@ -156,7 +187,9 @@ function AdminTunnelsPage() {
{tunnel.userName || "-"}
- {tunnel.userEmail &&
{tunnel.userEmail}
} + {tunnel.userEmail && ( +
{tunnel.userEmail}
+ )}
), @@ -167,7 +200,9 @@ function AdminTunnelsPage() { render: (tunnel) => (
- + {formatRelativeTime(tunnel.lastSeenAt)}
@@ -178,16 +213,38 @@ function AdminTunnelsPage() { return (
-

Tunnels

-

Live tunnel monitoring and management

+

+ Tunnels +

+

+ Live tunnel monitoring and management +

{/* Stats Cards */}
- } /> - } /> - } /> - } /> + } + /> + } + /> + } + /> + } + />
{/* Filters */} @@ -195,7 +252,10 @@ function AdminTunnelsPage() {
- +
-
@@ -212,7 +275,10 @@ function AdminTunnelsPage() { { setActiveOnly(e.target.checked); setPage(1); }} + onChange={(e) => { + setActiveOnly(e.target.checked); + setPage(1); + }} className="rounded border-white/20 bg-white/5 text-accent focus:ring-accent" /> Active only @@ -222,7 +288,10 @@ function AdminTunnelsPage() { {["", "http", "tcp", "udp"].map((protocol) => (
- +
); } diff --git a/apps/web/src/routes/admin/users.tsx b/apps/web/src/routes/admin/users.tsx index 284aa79e..1b154be4 100644 --- a/apps/web/src/routes/admin/users.tsx +++ b/apps/web/src/routes/admin/users.tsx @@ -3,7 +3,10 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Search, Users, Mail, Building2, Calendar } from "lucide-react"; import { appClient } from "@/lib/app-client"; -import { AdminDataTable, type Column } from "@/components/admin/admin-data-table"; +import { + AdminDataTable, + type Column, +} from "@/components/admin/admin-data-table"; import { UsersSkeleton } from "@/components/admin/admin-skeleton"; import { useAdminStore } from "@/lib/admin-store"; @@ -143,7 +146,9 @@ function AdminUsersPage() { key: "lastActive", header: "Last Active", render: (user) => ( - {formatRelativeTime(user.lastActive)} + + {formatRelativeTime(user.lastActive)} + ), }, ]; @@ -152,7 +157,9 @@ function AdminUsersPage() {
-

Users

+

+ Users +

{total.toLocaleString()} total users

diff --git a/apps/web/src/routes/api/$orgSlug/auth-tokens.ts b/apps/web/src/routes/api/$orgSlug/auth-tokens.ts index 8221a917..7a02d4fe 100644 --- a/apps/web/src/routes/api/$orgSlug/auth-tokens.ts +++ b/apps/web/src/routes/api/$orgSlug/auth-tokens.ts @@ -60,7 +60,10 @@ export const Route = createFileRoute("/api/$orgSlug/auth-tokens")({ const { id } = body as { id?: string }; if (!id) { - return Response.json({ error: "Token ID is required" }, { status: 400 }); + return Response.json( + { error: "Token ID is required" }, + { status: 400 }, + ); } await db diff --git a/apps/web/src/routes/api/$orgSlug/domains/$domainId.ts b/apps/web/src/routes/api/$orgSlug/domains/$domainId.ts index 59413ddb..06395567 100644 --- a/apps/web/src/routes/api/$orgSlug/domains/$domainId.ts +++ b/apps/web/src/routes/api/$orgSlug/domains/$domainId.ts @@ -4,9 +4,7 @@ import { db } from "../../../../db"; import { domains } from "../../../../db/app-schema"; import { requireOrgFromSlug } from "../../../../lib/org"; -export const Route = createFileRoute( - "/api/$orgSlug/domains/$domainId", -)({ +export const Route = createFileRoute("/api/$orgSlug/domains/$domainId")({ server: { handlers: { DELETE: async ({ request, params }) => { @@ -18,7 +16,10 @@ export const Route = createFileRoute( } if (!domainId) { - return Response.json({ error: "Domain ID required" }, { status: 400 }); + return Response.json( + { error: "Domain ID required" }, + { status: 400 }, + ); } const domain = await db.query.domains.findFirst({ diff --git a/apps/web/src/routes/api/$orgSlug/domains/index.ts b/apps/web/src/routes/api/$orgSlug/domains/index.ts index 6631667a..eca6efe9 100644 --- a/apps/web/src/routes/api/$orgSlug/domains/index.ts +++ b/apps/web/src/routes/api/$orgSlug/domains/index.ts @@ -31,7 +31,10 @@ export const Route = createFileRoute("/api/$orgSlug/domains/")({ const { domain } = body as { domain?: string }; if (!domain) { - return Response.json({ error: "Domain is required" }, { status: 400 }); + return Response.json( + { error: "Domain is required" }, + { status: 400 }, + ); } const domainParts = domain.trim().split("."); @@ -59,7 +62,10 @@ export const Route = createFileRoute("/api/$orgSlug/domains/")({ }); if (existingDomain) { - return Response.json({ error: "Domain already exists" }, { status: 400 }); + return Response.json( + { error: "Domain already exists" }, + { status: 400 }, + ); } const [newDomain] = await db diff --git a/apps/web/src/routes/api/$orgSlug/requests.ts b/apps/web/src/routes/api/$orgSlug/requests.ts index e784c28c..9d0f51c0 100644 --- a/apps/web/src/routes/api/$orgSlug/requests.ts +++ b/apps/web/src/routes/api/$orgSlug/requests.ts @@ -75,19 +75,31 @@ export const Route = createFileRoute("/api/$orgSlug/requests")({ }); } catch (error) { console.error("Failed to fetch requests:", error); - + // Provide more specific error messages let errorMessage = "Failed to fetch requests"; if (error instanceof Error) { - if (error.message.includes('SSL') || error.message.includes('ssl')) { - errorMessage = "Database SSL connection error. Please check TimescaleDB configuration."; - } else if (error.message.includes('connect') || error.message.includes('connection')) { - errorMessage = "Unable to connect to TimescaleDB. Please check database URL and network connectivity."; - } else if (error.message.includes('authentication') || error.message.includes('password')) { - errorMessage = "Database authentication failed. Please check credentials."; + if ( + error.message.includes("SSL") || + error.message.includes("ssl") + ) { + errorMessage = + "Database SSL connection error. Please check TimescaleDB configuration."; + } else if ( + error.message.includes("connect") || + error.message.includes("connection") + ) { + errorMessage = + "Unable to connect to TimescaleDB. Please check database URL and network connectivity."; + } else if ( + error.message.includes("authentication") || + error.message.includes("password") + ) { + errorMessage = + "Database authentication failed. Please check credentials."; } } - + return Response.json({ error: errorMessage }, { status: 500 }); } }, diff --git a/apps/web/src/routes/api/$orgSlug/requests/capture.ts b/apps/web/src/routes/api/$orgSlug/requests/capture.ts index c3d45696..86b88348 100644 --- a/apps/web/src/routes/api/$orgSlug/requests/capture.ts +++ b/apps/web/src/routes/api/$orgSlug/requests/capture.ts @@ -18,7 +18,10 @@ export const Route = createFileRoute("/api/$orgSlug/requests/capture")({ const { tunnelId, timestamp } = body; if (!tunnelId || !timestamp) { - return Response.json({ error: "tunnelId and timestamp are required" }, { status: 400 }); + return Response.json( + { error: "tunnelId and timestamp are required" }, + { status: 400 }, + ); } // Query TimescaleDB for request capture data @@ -45,23 +48,34 @@ export const Route = createFileRoute("/api/$orgSlug/requests/capture")({ AND timestamp BETWEEN $3 AND $4 ORDER BY ABS(EXTRACT(EPOCH FROM (timestamp - $5))) ASC LIMIT 1`, - [tunnelId, orgContext.organization.id, beforeTime, afterTime, timestampDate] + [ + tunnelId, + orgContext.organization.id, + beforeTime, + afterTime, + timestampDate, + ], ); if (result.rows.length === 0) { - return Response.json({ error: "Request capture not found" }, { status: 404 }); + return Response.json( + { error: "Request capture not found" }, + { status: 404 }, + ); } const capture = result.rows[0]; // Parse JSON headers - const requestHeaders = typeof capture.request_headers === 'string' - ? JSON.parse(capture.request_headers) - : capture.request_headers; - - const responseHeaders = typeof capture.response_headers === 'string' - ? JSON.parse(capture.response_headers) - : capture.response_headers; + const requestHeaders = + typeof capture.request_headers === "string" + ? JSON.parse(capture.request_headers) + : capture.request_headers; + + const responseHeaders = + typeof capture.response_headers === "string" + ? JSON.parse(capture.response_headers) + : capture.response_headers; // Decode base64 bodies if they exist let requestBody = null; @@ -69,7 +83,10 @@ export const Route = createFileRoute("/api/$orgSlug/requests/capture")({ if (capture.request_body) { try { - requestBody = Buffer.from(capture.request_body, 'base64').toString('utf-8'); + requestBody = Buffer.from( + capture.request_body, + "base64", + ).toString("utf-8"); } catch (e) { requestBody = capture.request_body; // fallback to raw if not base64 } @@ -77,7 +94,10 @@ export const Route = createFileRoute("/api/$orgSlug/requests/capture")({ if (capture.response_body) { try { - responseBody = Buffer.from(capture.response_body, 'base64').toString('utf-8'); + responseBody = Buffer.from( + capture.response_body, + "base64", + ).toString("utf-8"); } catch (e) { responseBody = capture.response_body; // fallback to raw if not base64 } @@ -102,23 +122,32 @@ export const Route = createFileRoute("/api/$orgSlug/requests/capture")({ }); } catch (error) { console.error("Error fetching request capture:", error); - + // Provide more specific error messages let errorMessage = "Failed to fetch request capture"; if (error instanceof Error) { - if (error.message.includes('SSL') || error.message.includes('ssl')) { - errorMessage = "Database SSL connection error. Please check TimescaleDB configuration."; - } else if (error.message.includes('connect') || error.message.includes('connection')) { - errorMessage = "Unable to connect to TimescaleDB. Please check database URL and network connectivity."; - } else if (error.message.includes('authentication') || error.message.includes('password')) { - errorMessage = "Database authentication failed. Please check credentials."; + if ( + error.message.includes("SSL") || + error.message.includes("ssl") + ) { + errorMessage = + "Database SSL connection error. Please check TimescaleDB configuration."; + } else if ( + error.message.includes("connect") || + error.message.includes("connection") + ) { + errorMessage = + "Unable to connect to TimescaleDB. Please check database URL and network connectivity."; + } else if ( + error.message.includes("authentication") || + error.message.includes("password") + ) { + errorMessage = + "Database authentication failed. Please check credentials."; } } - - return Response.json( - { error: errorMessage }, - { status: 500 } - ); + + return Response.json({ error: errorMessage }, { status: 500 }); } }, }, diff --git a/apps/web/src/routes/api/$orgSlug/requests/replay.ts b/apps/web/src/routes/api/$orgSlug/requests/replay.ts index 01b4898b..3887dae6 100644 --- a/apps/web/src/routes/api/$orgSlug/requests/replay.ts +++ b/apps/web/src/routes/api/$orgSlug/requests/replay.ts @@ -17,8 +17,10 @@ export const Route = createFileRoute("/api/$orgSlug/requests/replay")({ const contentLength = request.headers.get("content-length"); if (contentLength && parseInt(contentLength, 10) > MAX_BODY_SIZE) { return Response.json( - { error: `Request body too large. Maximum size is ${MAX_BODY_SIZE / 1024 / 1024}MB` }, - { status: 413 } + { + error: `Request body too large. Maximum size is ${MAX_BODY_SIZE / 1024 / 1024}MB`, + }, + { status: 413 }, ); } @@ -28,7 +30,7 @@ export const Route = createFileRoute("/api/$orgSlug/requests/replay")({ if (!url || !method) { return Response.json( { error: "url and method are required" }, - { status: 400 } + { status: 400 }, ); } @@ -37,19 +39,22 @@ export const Route = createFileRoute("/api/$orgSlug/requests/replay")({ if (!urlValidation.valid) { return Response.json( { error: urlValidation.error || "Invalid URL" }, - { status: 400 } + { status: 400 }, ); } // Validate requestBody size to prevent memory exhaustion if (requestBody) { - const bodySize = typeof requestBody === 'string' - ? Buffer.byteLength(requestBody, 'utf8') - : Buffer.byteLength(JSON.stringify(requestBody), 'utf8'); + const bodySize = + typeof requestBody === "string" + ? Buffer.byteLength(requestBody, "utf8") + : Buffer.byteLength(JSON.stringify(requestBody), "utf8"); if (bodySize > MAX_BODY_SIZE) { return Response.json( - { error: `Request body too large. Maximum size is ${MAX_BODY_SIZE / 1024 / 1024}MB` }, - { status: 413 } + { + error: `Request body too large. Maximum size is ${MAX_BODY_SIZE / 1024 / 1024}MB`, + }, + { status: 413 }, ); } } @@ -97,13 +102,16 @@ export const Route = createFileRoute("/api/$orgSlug/requests/replay")({ } catch (error) { console.error("Error replaying request:", error); return Response.json( - { - error: error instanceof Error ? error.message : "Failed to replay request" + { + error: + error instanceof Error + ? error.message + : "Failed to replay request", }, - { status: 500 } + { status: 500 }, ); } }, }, }, -}); \ No newline at end of file +}); diff --git a/apps/web/src/routes/api/$orgSlug/settings.ts b/apps/web/src/routes/api/$orgSlug/settings.ts index 40abbf18..aa65bc86 100644 --- a/apps/web/src/routes/api/$orgSlug/settings.ts +++ b/apps/web/src/routes/api/$orgSlug/settings.ts @@ -25,7 +25,7 @@ export const Route = createFileRoute("/api/$orgSlug/settings")({ console.error("Error fetching organization settings:", error); return Response.json( { error: "Failed to fetch settings" }, - { status: 500 } + { status: 500 }, ); } }, @@ -41,7 +41,7 @@ export const Route = createFileRoute("/api/$orgSlug/settings")({ if (typeof fullCaptureEnabled !== "boolean") { return Response.json( { error: "fullCaptureEnabled must be a boolean" }, - { status: 400 } + { status: 400 }, ); } @@ -65,10 +65,10 @@ export const Route = createFileRoute("/api/$orgSlug/settings")({ console.error("Error updating organization settings:", error); return Response.json( { error: "Failed to update settings" }, - { status: 500 } + { status: 500 }, ); } }, }, }, -}); \ No newline at end of file +}); diff --git a/apps/web/src/routes/api/$orgSlug/stats/overview.ts b/apps/web/src/routes/api/$orgSlug/stats/overview.ts index 9bfbebe3..c7959f89 100644 --- a/apps/web/src/routes/api/$orgSlug/stats/overview.ts +++ b/apps/web/src/routes/api/$orgSlug/stats/overview.ts @@ -200,7 +200,10 @@ export const Route = createFileRoute("/api/$orgSlug/stats/overview")({ chartParams = [organizationId, `${days} days`]; } - const chartDataResult = await tigerData.query(chartQuery, chartParams); + const chartDataResult = await tigerData.query( + chartQuery, + chartParams, + ); const chartData = chartDataResult.rows; return Response.json({ @@ -217,7 +220,10 @@ export const Route = createFileRoute("/api/$orgSlug/stats/overview")({ }); } catch (error) { console.error("Failed to fetch stats overview:", error); - return Response.json({ error: "Failed to fetch stats" }, { status: 500 }); + return Response.json( + { error: "Failed to fetch stats" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/api/$orgSlug/stats/protocol.ts b/apps/web/src/routes/api/$orgSlug/stats/protocol.ts index 7eb028e8..9f27bd2b 100644 --- a/apps/web/src/routes/api/$orgSlug/stats/protocol.ts +++ b/apps/web/src/routes/api/$orgSlug/stats/protocol.ts @@ -20,7 +20,10 @@ export const Route = createFileRoute("/api/$orgSlug/stats/protocol")({ } if (!tunnelId) { - return Response.json({ error: "Tunnel ID required" }, { status: 400 }); + return Response.json( + { error: "Tunnel ID required" }, + { status: 400 }, + ); } const [tunnel] = await db @@ -213,7 +216,10 @@ export const Route = createFileRoute("/api/$orgSlug/stats/protocol")({ }); } catch (error) { console.error("Failed to fetch protocol stats:", error); - return Response.json({ error: "Failed to fetch stats" }, { status: 500 }); + return Response.json( + { error: "Failed to fetch stats" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/api/$orgSlug/stats/tunnel.ts b/apps/web/src/routes/api/$orgSlug/stats/tunnel.ts index f3ed6a31..399fb0e0 100644 --- a/apps/web/src/routes/api/$orgSlug/stats/tunnel.ts +++ b/apps/web/src/routes/api/$orgSlug/stats/tunnel.ts @@ -20,7 +20,10 @@ export const Route = createFileRoute("/api/$orgSlug/stats/tunnel")({ } if (!tunnelId) { - return Response.json({ error: "Tunnel ID required" }, { status: 400 }); + return Response.json( + { error: "Tunnel ID required" }, + { status: 400 }, + ); } const [tunnel] = await db @@ -199,7 +202,10 @@ export const Route = createFileRoute("/api/$orgSlug/stats/tunnel")({ }); } catch (error) { console.error("Failed to fetch tunnel stats:", error); - return Response.json({ error: "Failed to fetch stats" }, { status: 500 }); + return Response.json( + { error: "Failed to fetch stats" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/api/$orgSlug/subdomains/$subdomainId.ts b/apps/web/src/routes/api/$orgSlug/subdomains/$subdomainId.ts index 01b8f1e9..dd0e4ef8 100644 --- a/apps/web/src/routes/api/$orgSlug/subdomains/$subdomainId.ts +++ b/apps/web/src/routes/api/$orgSlug/subdomains/$subdomainId.ts @@ -21,7 +21,10 @@ export const Route = createFileRoute("/api/$orgSlug/subdomains/$subdomainId")({ .where(eq(subdomains.id, subdomainId)); if (!subdomain) { - return Response.json({ error: "Subdomain not found" }, { status: 404 }); + return Response.json( + { error: "Subdomain not found" }, + { status: 404 }, + ); } if (subdomain.organizationId !== orgContext.organization.id) { diff --git a/apps/web/src/routes/api/$orgSlug/subdomains/index.ts b/apps/web/src/routes/api/$orgSlug/subdomains/index.ts index c9a5d492..af3f80a2 100644 --- a/apps/web/src/routes/api/$orgSlug/subdomains/index.ts +++ b/apps/web/src/routes/api/$orgSlug/subdomains/index.ts @@ -35,7 +35,10 @@ export const Route = createFileRoute("/api/$orgSlug/subdomains/")({ const { subdomain } = body as { subdomain?: string }; if (!subdomain) { - return Response.json({ error: "Subdomain is required" }, { status: 400 }); + return Response.json( + { error: "Subdomain is required" }, + { status: 400 }, + ); } const subdomainRegex = /^[a-z0-9-]+$/; @@ -106,14 +109,20 @@ export const Route = createFileRoute("/api/$orgSlug/subdomains/")({ }); if ("error" in result) { - return Response.json({ error: result.error }, { status: result.status }); + return Response.json( + { error: result.error }, + { status: result.status }, + ); } return Response.json({ subdomain: result.subdomain }); } catch (error: any) { // Handle unique constraint violation (race condition fallback) if (error?.code === "23505") { - return Response.json({ error: "Subdomain already taken" }, { status: 409 }); + return Response.json( + { error: "Subdomain already taken" }, + { status: 409 }, + ); } throw error; } diff --git a/apps/web/src/routes/api/admin/charts.ts b/apps/web/src/routes/api/admin/charts.ts index f33bcf59..d1a3aa90 100644 --- a/apps/web/src/routes/api/admin/charts.ts +++ b/apps/web/src/routes/api/admin/charts.ts @@ -1,6 +1,11 @@ import { createFileRoute } from "@tanstack/react-router"; import { db } from "../../../db"; -import { users, organizations, tunnels, subscriptions } from "../../../db/schema"; +import { + users, + organizations, + tunnels, + subscriptions, +} from "../../../db/schema"; import { redis } from "../../../lib/redis"; import { hashToken } from "../../../lib/hash"; import { sql, count, gte, desc, eq } from "drizzle-orm"; @@ -29,7 +34,9 @@ export const Route = createFileRoute("/api/admin/charts")({ const now = new Date(); // User signups over time (last 30 days, daily) - const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const thirtyDaysAgo = new Date( + now.getTime() - 30 * 24 * 60 * 60 * 1000, + ); const userSignups = await db .select({ date: sql`DATE(${users.createdAt})`.as("date"), @@ -91,7 +98,10 @@ export const Route = createFileRoute("/api/admin/charts")({ } catch (e) { // TimescaleDB might not be available; log in non-production for debugging if (process.env.NODE_ENV !== "production") { - console.error("Failed to fetch hourly request activity from TimescaleDB:", e); + console.error( + "Failed to fetch hourly request activity from TimescaleDB:", + e, + ); } } @@ -121,13 +131,17 @@ export const Route = createFileRoute("/api/admin/charts")({ tunnelCount: count(), }) .from(tunnels) - .leftJoin(organizations, eq(tunnels.organizationId, organizations.id)) + .leftJoin( + organizations, + eq(tunnels.organizationId, organizations.id), + ) .groupBy(tunnels.organizationId, organizations.name) .orderBy(desc(count())) .limit(10); // Weekly active tunnels trend (from TimescaleDB) - let weeklyTunnelTrend: { day: string; avg: number; max: number }[] = []; + let weeklyTunnelTrend: { day: string; avg: number; max: number }[] = + []; try { const result = await tigerData.query(` SELECT @@ -178,7 +192,10 @@ export const Route = createFileRoute("/api/admin/charts")({ }); } catch (error) { console.error("Admin charts error:", error); - return Response.json({ error: "Failed to fetch chart data" }, { status: 500 }); + return Response.json( + { error: "Failed to fetch chart data" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/api/admin/login.ts b/apps/web/src/routes/api/admin/login.ts index 2805b57a..1abd5b81 100644 --- a/apps/web/src/routes/api/admin/login.ts +++ b/apps/web/src/routes/api/admin/login.ts @@ -35,7 +35,12 @@ export const Route = createFileRoute("/api/admin/login")({ const token = randomUUID(); const tokenHash = hashToken(token); - await redis.set(`admin:token:${tokenHash}`, "1", "EX", TOKEN_TTL_SECONDS); + await redis.set( + `admin:token:${tokenHash}`, + "1", + "EX", + TOKEN_TTL_SECONDS, + ); return Response.json({ token, expiresIn: TOKEN_TTL_SECONDS }); }, diff --git a/apps/web/src/routes/api/admin/organizations.$slug.ts b/apps/web/src/routes/api/admin/organizations.$slug.ts index 9c18ead8..947feafb 100644 --- a/apps/web/src/routes/api/admin/organizations.$slug.ts +++ b/apps/web/src/routes/api/admin/organizations.$slug.ts @@ -1,6 +1,13 @@ import { createFileRoute } from "@tanstack/react-router"; import { db } from "../../../db"; -import { organizations, members, subscriptions, tunnels, subdomains, domains } from "../../../db/schema"; +import { + organizations, + members, + subscriptions, + tunnels, + subdomains, + domains, +} from "../../../db/schema"; import { redis } from "../../../lib/redis"; import { hashToken } from "../../../lib/hash"; import { eq, count, gte, and } from "drizzle-orm"; @@ -10,33 +17,71 @@ export const Route = createFileRoute("/api/admin/organizations/$slug")({ handlers: { GET: async ({ request, params }) => { const authHeader = request.headers.get("authorization") || ""; - const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""; - if (!token) return Response.json({ error: "Unauthorized" }, { status: 401 }); + const token = authHeader.startsWith("Bearer ") + ? authHeader.slice(7) + : ""; + if (!token) + return Response.json({ error: "Unauthorized" }, { status: 401 }); const tokenKey = `admin:token:${hashToken(token)}`; const exists = await redis.get(tokenKey); - if (!exists) return Response.json({ error: "Forbidden" }, { status: 403 }); + if (!exists) + return Response.json({ error: "Forbidden" }, { status: 403 }); try { const { slug } = params; - const [org] = await db.select().from(organizations).where(eq(organizations.slug, slug)).limit(1); - if (!org) return Response.json({ error: "Organization not found" }, { status: 404 }); + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.slug, slug)) + .limit(1); + if (!org) + return Response.json( + { error: "Organization not found" }, + { status: 404 }, + ); - const [sub] = await db.select().from(subscriptions).where(eq(subscriptions.organizationId, org.id)).limit(1); - const [memberCount] = await db.select({ count: count() }).from(members).where(eq(members.organizationId, org.id)); + const [sub] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.organizationId, org.id)) + .limit(1); + const [memberCount] = await db + .select({ count: count() }) + .from(members) + .where(eq(members.organizationId, org.id)); const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); - const [activeTunnelCount] = await db.select({ count: count() }).from(tunnels) - .where(and(eq(tunnels.organizationId, org.id), gte(tunnels.lastSeenAt, fiveMinutesAgo))); - const [totalTunnelCount] = await db.select({ count: count() }).from(tunnels).where(eq(tunnels.organizationId, org.id)); - const [subdomainCount] = await db.select({ count: count() }).from(subdomains).where(eq(subdomains.organizationId, org.id)); - const [domainCount] = await db.select({ count: count() }).from(domains).where(eq(domains.organizationId, org.id)); + const [activeTunnelCount] = await db + .select({ count: count() }) + .from(tunnels) + .where( + and( + eq(tunnels.organizationId, org.id), + gte(tunnels.lastSeenAt, fiveMinutesAgo), + ), + ); + const [totalTunnelCount] = await db + .select({ count: count() }) + .from(tunnels) + .where(eq(tunnels.organizationId, org.id)); + const [subdomainCount] = await db + .select({ count: count() }) + .from(subdomains) + .where(eq(subdomains.organizationId, org.id)); + const [domainCount] = await db + .select({ count: count() }) + .from(domains) + .where(eq(domains.organizationId, org.id)); - const memberList = await db.select({ - id: members.id, - userId: members.userId, - role: members.role, - createdAt: members.createdAt, - }).from(members).where(eq(members.organizationId, org.id)); + const memberList = await db + .select({ + id: members.id, + userId: members.userId, + role: members.role, + createdAt: members.createdAt, + }) + .from(members) + .where(eq(members.organizationId, org.id)); return Response.json({ organization: org, @@ -52,42 +97,67 @@ export const Route = createFileRoute("/api/admin/organizations/$slug")({ }); } catch (error) { console.error("Admin org detail error:", error); - return Response.json({ error: "Failed to fetch organization" }, { status: 500 }); + return Response.json( + { error: "Failed to fetch organization" }, + { status: 500 }, + ); } }, PATCH: async ({ request, params }) => { const authHeader = request.headers.get("authorization") || ""; - const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""; - if (!token) return Response.json({ error: "Unauthorized" }, { status: 401 }); + const token = authHeader.startsWith("Bearer ") + ? authHeader.slice(7) + : ""; + if (!token) + return Response.json({ error: "Unauthorized" }, { status: 401 }); const tokenKey = `admin:token:${hashToken(token)}`; const exists = await redis.get(tokenKey); - if (!exists) return Response.json({ error: "Forbidden" }, { status: 403 }); + if (!exists) + return Response.json({ error: "Forbidden" }, { status: 403 }); try { const { slug } = params; const body = await request.json(); - const [org] = await db.select().from(organizations).where(eq(organizations.slug, slug)).limit(1); - if (!org) return Response.json({ error: "Organization not found" }, { status: 404 }); + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.slug, slug)) + .limit(1); + if (!org) + return Response.json( + { error: "Organization not found" }, + { status: 404 }, + ); // Update organization fields if (body.name || body.slug) { - await db.update(organizations).set({ - ...(body.name && { name: body.name }), - ...(body.slug && { slug: body.slug }), - }).where(eq(organizations.id, org.id)); + await db + .update(organizations) + .set({ + ...(body.name && { name: body.name }), + ...(body.slug && { slug: body.slug }), + }) + .where(eq(organizations.id, org.id)); } // Update subscription if (body.plan || body.status) { - const [existingSub] = await db.select().from(subscriptions).where(eq(subscriptions.organizationId, org.id)).limit(1); + const [existingSub] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.organizationId, org.id)) + .limit(1); if (existingSub) { - await db.update(subscriptions).set({ - ...(body.plan && { plan: body.plan }), - ...(body.status && { status: body.status }), - updatedAt: new Date(), - }).where(eq(subscriptions.organizationId, org.id)); + await db + .update(subscriptions) + .set({ + ...(body.plan && { plan: body.plan }), + ...(body.status && { status: body.status }), + updatedAt: new Date(), + }) + .where(eq(subscriptions.organizationId, org.id)); } else { await db.insert(subscriptions).values({ id: crypto.randomUUID(), @@ -101,7 +171,10 @@ export const Route = createFileRoute("/api/admin/organizations/$slug")({ return Response.json({ success: true }); } catch (error) { console.error("Admin org update error:", error); - return Response.json({ error: "Failed to update organization" }, { status: 500 }); + return Response.json( + { error: "Failed to update organization" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/api/admin/organizations.ts b/apps/web/src/routes/api/admin/organizations.ts index 3df08feb..df133ea3 100644 --- a/apps/web/src/routes/api/admin/organizations.ts +++ b/apps/web/src/routes/api/admin/organizations.ts @@ -1,6 +1,11 @@ import { createFileRoute } from "@tanstack/react-router"; import { db } from "../../../db"; -import { organizations, members, tunnels, subscriptions } from "../../../db/schema"; +import { + organizations, + members, + tunnels, + subscriptions, +} from "../../../db/schema"; import { redis } from "../../../lib/redis"; import { hashToken } from "../../../lib/hash"; import { count, desc, like, or, inArray, gte, and } from "drizzle-orm"; @@ -30,7 +35,10 @@ export const Route = createFileRoute("/api/admin/organizations")({ const pageParam = parseInt(url.searchParams.get("page") || "1"); const limitParam = parseInt(url.searchParams.get("limit") || "20"); const page = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam; - const limit = Number.isNaN(limitParam) || limitParam < 1 ? 20 : Math.min(limitParam, 100); + const limit = + Number.isNaN(limitParam) || limitParam < 1 + ? 20 + : Math.min(limitParam, 100); const search = url.searchParams.get("search") || ""; const offset = (page - 1) * limit; @@ -38,7 +46,7 @@ export const Route = createFileRoute("/api/admin/organizations")({ const searchCondition = search ? or( like(organizations.name, `%${search}%`), - like(organizations.slug, `%${search}%`) + like(organizations.slug, `%${search}%`), ) : undefined; @@ -79,7 +87,7 @@ export const Route = createFileRoute("/api/admin/organizations")({ : []; const memberCountMap = new Map( - memberCounts.map((mc) => [mc.organizationId, mc.count]) + memberCounts.map((mc) => [mc.organizationId, mc.count]), ); // Get tunnel counts (active tunnels - seen in last 5 minutes) @@ -95,14 +103,14 @@ export const Route = createFileRoute("/api/admin/organizations")({ .where( and( inArray(tunnels.organizationId, orgIds), - gte(tunnels.lastSeenAt, fiveMinutesAgo) - ) + gte(tunnels.lastSeenAt, fiveMinutesAgo), + ), ) .groupBy(tunnels.organizationId) : []; const tunnelCountMap = new Map( - tunnelCounts.map((tc) => [tc.organizationId, tc.count]) + tunnelCounts.map((tc) => [tc.organizationId, tc.count]), ); // Get subscription plans @@ -122,7 +130,7 @@ export const Route = createFileRoute("/api/admin/organizations")({ subscriptionPlans.map((s) => [ s.organizationId, { plan: s.plan, status: s.status }, - ]) + ]), ); const orgsWithMeta = orgList.map((org) => ({ @@ -145,7 +153,7 @@ export const Route = createFileRoute("/api/admin/organizations")({ console.error("Admin organizations error:", error); return Response.json( { error: "Failed to fetch organizations" }, - { status: 500 } + { status: 500 }, ); } }, diff --git a/apps/web/src/routes/api/admin/overview.ts b/apps/web/src/routes/api/admin/overview.ts index a3ed3845..88c7dc4a 100644 --- a/apps/web/src/routes/api/admin/overview.ts +++ b/apps/web/src/routes/api/admin/overview.ts @@ -1,6 +1,11 @@ import { createFileRoute } from "@tanstack/react-router"; import { db } from "../../../db"; -import { users, organizations, tunnels, subscriptions } from "../../../db/schema"; +import { + users, + organizations, + tunnels, + subscriptions, +} from "../../../db/schema"; import { redis } from "../../../lib/redis"; import { hashToken } from "../../../lib/hash"; import { SUBSCRIPTION_PLANS } from "../../../lib/subscription-plans"; @@ -32,9 +37,7 @@ export const Route = createFileRoute("/api/admin/overview")({ const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // Get user stats - const [totalUsers] = await db - .select({ count: count() }) - .from(users); + const [totalUsers] = await db.select({ count: count() }).from(users); const [newUsersToday] = await db .select({ count: count() }) @@ -47,8 +50,8 @@ export const Route = createFileRoute("/api/admin/overview")({ .where( and( gte(users.createdAt, oneWeekAgo), - sql`${users.createdAt} < ${oneDayAgo}` - ) + sql`${users.createdAt} < ${oneDayAgo}`, + ), ); // Calculate user growth (comparing today vs same period last week) @@ -75,8 +78,8 @@ export const Route = createFileRoute("/api/admin/overview")({ .where( and( gte(organizations.createdAt, oneWeekAgo), - sql`${organizations.createdAt} < ${oneDayAgo}` - ) + sql`${organizations.createdAt} < ${oneDayAgo}`, + ), ); const orgGrowth = @@ -100,7 +103,7 @@ export const Route = createFileRoute("/api/admin/overview")({ "MATCH", "org:*:online_tunnels", "COUNT", - 100 + 100, ); cursor = nextCursor; for (const key of keys) { @@ -142,10 +145,11 @@ export const Route = createFileRoute("/api/admin/overview")({ // Calculate MRR using actual plan prices const mrr = Object.entries(byPlan).reduce( (total, [plan, planCount]) => { - const planConfig = SUBSCRIPTION_PLANS[plan as keyof typeof SUBSCRIPTION_PLANS]; + const planConfig = + SUBSCRIPTION_PLANS[plan as keyof typeof SUBSCRIPTION_PLANS]; return total + (planConfig?.price || 0) * planCount; }, - 0 + 0, ); return Response.json({ @@ -169,7 +173,10 @@ export const Route = createFileRoute("/api/admin/overview")({ }); } catch (error) { console.error("Admin overview error:", error); - return Response.json({ error: "Failed to fetch overview" }, { status: 500 }); + return Response.json( + { error: "Failed to fetch overview" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/api/admin/stats.ts b/apps/web/src/routes/api/admin/stats.ts index 2b8f3776..2604f6a2 100644 --- a/apps/web/src/routes/api/admin/stats.ts +++ b/apps/web/src/routes/api/admin/stats.ts @@ -74,7 +74,10 @@ export const Route = createFileRoute("/api/admin/stats")({ return Response.json(result.rows); } catch (error) { console.error("TimescaleDB query error:", error); - return Response.json({ error: "Failed to fetch stats" }, { status: 500 }); + return Response.json( + { error: "Failed to fetch stats" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/api/admin/subscriptions.ts b/apps/web/src/routes/api/admin/subscriptions.ts index 0fd47ec1..64e887c7 100644 --- a/apps/web/src/routes/api/admin/subscriptions.ts +++ b/apps/web/src/routes/api/admin/subscriptions.ts @@ -28,8 +28,14 @@ export const Route = createFileRoute("/api/admin/subscriptions")({ try { const url = new URL(request.url); - const page = Math.max(1, parseInt(url.searchParams.get("page") || "1") || 1); - const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get("limit") || "20") || 20)); + const page = Math.max( + 1, + parseInt(url.searchParams.get("page") || "1") || 1, + ); + const limit = Math.min( + 100, + Math.max(1, parseInt(url.searchParams.get("limit") || "20") || 20), + ); const planFilter = url.searchParams.get("plan") || ""; const offset = (page - 1) * limit; @@ -61,7 +67,7 @@ export const Route = createFileRoute("/api/admin/subscriptions")({ .from(subscriptions) .leftJoin( organizations, - eq(subscriptions.organizationId, organizations.id) + eq(subscriptions.organizationId, organizations.id), ) .where(filterCondition) .orderBy(desc(subscriptions.updatedAt)) @@ -83,7 +89,8 @@ export const Route = createFileRoute("/api/admin/subscriptions")({ planDistribution.forEach((item) => { if (item.status === "active") { - activeByPlan[item.plan] = (activeByPlan[item.plan] || 0) + item.count; + activeByPlan[item.plan] = + (activeByPlan[item.plan] || 0) + item.count; } else if (item.status === "cancelled") { cancelledByPlan[item.plan] = (cancelledByPlan[item.plan] || 0) + item.count; @@ -93,19 +100,20 @@ export const Route = createFileRoute("/api/admin/subscriptions")({ // Calculate stats using actual plan prices const mrr = Object.entries(activeByPlan).reduce( (total, [plan, planCount]) => { - const planConfig = SUBSCRIPTION_PLANS[plan as keyof typeof SUBSCRIPTION_PLANS]; + const planConfig = + SUBSCRIPTION_PLANS[plan as keyof typeof SUBSCRIPTION_PLANS]; return total + (planConfig?.price || 0) * planCount; }, - 0 + 0, ); const totalActive = Object.values(activeByPlan).reduce( (a, b) => a + b, - 0 + 0, ); const totalCancelled = Object.values(cancelledByPlan).reduce( (a, b) => a + b, - 0 + 0, ); // Get recent changes (last 30 days) @@ -133,7 +141,7 @@ export const Route = createFileRoute("/api/admin/subscriptions")({ console.error("Admin subscriptions error:", error); return Response.json( { error: "Failed to fetch subscriptions" }, - { status: 500 } + { status: 500 }, ); } }, diff --git a/apps/web/src/routes/api/admin/tunnels.ts b/apps/web/src/routes/api/admin/tunnels.ts index fa6ec4b9..ac0617cf 100644 --- a/apps/web/src/routes/api/admin/tunnels.ts +++ b/apps/web/src/routes/api/admin/tunnels.ts @@ -27,8 +27,14 @@ export const Route = createFileRoute("/api/admin/tunnels")({ try { const url = new URL(request.url); - const page = Math.max(1, parseInt(url.searchParams.get("page") || "1") || 1); - const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get("limit") || "20") || 20)); + const page = Math.max( + 1, + parseInt(url.searchParams.get("page") || "1") || 1, + ); + const limit = Math.min( + 100, + Math.max(1, parseInt(url.searchParams.get("limit") || "20") || 20), + ); const search = url.searchParams.get("search") || ""; const protocol = url.searchParams.get("protocol") || ""; const activeOnly = url.searchParams.get("active") === "true"; @@ -43,7 +49,7 @@ export const Route = createFileRoute("/api/admin/tunnels")({ "MATCH", "org:*:online_tunnels", "COUNT", - 100 + 100, ); cursor = nextCursor; for (const key of keys) { @@ -59,8 +65,8 @@ export const Route = createFileRoute("/api/admin/tunnels")({ conditions.push( or( like(tunnels.url, `%${search}%`), - like(tunnels.name, `%${search}%`) - ) + like(tunnels.name, `%${search}%`), + ), ); } @@ -90,7 +96,7 @@ export const Route = createFileRoute("/api/admin/tunnels")({ conditions.length > 0 ? sql`${sql.join( conditions.map((c) => sql`(${c})`), - sql` AND ` + sql` AND `, )}` : undefined; @@ -118,7 +124,10 @@ export const Route = createFileRoute("/api/admin/tunnels")({ }) .from(tunnels) .leftJoin(users, eq(tunnels.userId, users.id)) - .leftJoin(organizations, eq(tunnels.organizationId, organizations.id)) + .leftJoin( + organizations, + eq(tunnels.organizationId, organizations.id), + ) .orderBy(desc(tunnels.lastSeenAt)) .limit(limit) .offset(offset); @@ -151,13 +160,16 @@ export const Route = createFileRoute("/api/admin/tunnels")({ total: totalResult.count, active: onlineTunnelIds.size, byProtocol: Object.fromEntries( - protocolStats.map((p) => [p.protocol, p.count]) + protocolStats.map((p) => [p.protocol, p.count]), ), }, }); } catch (error) { console.error("Admin tunnels error:", error); - return Response.json({ error: "Failed to fetch tunnels" }, { status: 500 }); + return Response.json( + { error: "Failed to fetch tunnels" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/api/admin/users.ts b/apps/web/src/routes/api/admin/users.ts index 7c429bfd..d9a0320e 100644 --- a/apps/web/src/routes/api/admin/users.ts +++ b/apps/web/src/routes/api/admin/users.ts @@ -27,8 +27,14 @@ export const Route = createFileRoute("/api/admin/users")({ try { const url = new URL(request.url); - const page = Math.max(1, parseInt(url.searchParams.get("page") || "1") || 1); - const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get("limit") || "20") || 20)); + const page = Math.max( + 1, + parseInt(url.searchParams.get("page") || "1") || 1, + ); + const limit = Math.min( + 100, + Math.max(1, parseInt(url.searchParams.get("limit") || "20") || 20), + ); const search = url.searchParams.get("search") || ""; const offset = (page - 1) * limit; @@ -36,7 +42,7 @@ export const Route = createFileRoute("/api/admin/users")({ const searchCondition = search ? or( like(users.email, `%${search}%`), - like(users.name, `%${search}%`) + like(users.name, `%${search}%`), ) : sql`1=1`; @@ -77,7 +83,7 @@ export const Route = createFileRoute("/api/admin/users")({ : []; const orgCountMap = new Map( - orgCounts.map((oc) => [oc.userId, oc.count]) + orgCounts.map((oc) => [oc.userId, oc.count]), ); // Get last active (most recent session) for each user @@ -87,7 +93,7 @@ export const Route = createFileRoute("/api/admin/users")({ .select({ userId: sessions.userId, lastActive: sql`MAX(${sessions.updatedAt})`.as( - "lastActive" + "lastActive", ), }) .from(sessions) @@ -96,7 +102,7 @@ export const Route = createFileRoute("/api/admin/users")({ : []; const lastActiveMap = new Map( - lastActiveSessions.map((s) => [s.userId, s.lastActive]) + lastActiveSessions.map((s) => [s.userId, s.lastActive]), ); const usersWithMeta = userList.map((user) => ({ @@ -113,7 +119,10 @@ export const Route = createFileRoute("/api/admin/users")({ }); } catch (error) { console.error("Admin users error:", error); - return Response.json({ error: "Failed to fetch users" }, { status: 500 }); + return Response.json( + { error: "Failed to fetch users" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/api/cli/complete.ts b/apps/web/src/routes/api/cli/complete.ts index cad6b382..29a3d151 100644 --- a/apps/web/src/routes/api/cli/complete.ts +++ b/apps/web/src/routes/api/cli/complete.ts @@ -22,7 +22,10 @@ export const Route = createFileRoute("/api/cli/complete")({ const { code } = body; if (!code) { - return Response.json({ error: "Missing required fields" }, { status: 400 }); + return Response.json( + { error: "Missing required fields" }, + { status: 400 }, + ); } // Verify session exists and is pending diff --git a/apps/web/src/routes/api/cli/exchange.ts b/apps/web/src/routes/api/cli/exchange.ts index c9a02c97..0f8d617b 100644 --- a/apps/web/src/routes/api/cli/exchange.ts +++ b/apps/web/src/routes/api/cli/exchange.ts @@ -3,7 +3,11 @@ import { db } from "../../../db"; import { cliOrgTokens, members, cliUserTokens } from "../../../db/auth-schema"; import { eq, and, gt } from "drizzle-orm"; import { randomUUID, randomBytes } from "crypto"; -import {rateLimiters, getClientIdentifier, createRateLimitResponse,} from "../../../lib/rate-limiter"; +import { + rateLimiters, + getClientIdentifier, + createRateLimitResponse, +} from "../../../lib/rate-limiter"; export const Route = createFileRoute("/api/cli/exchange")({ server: { @@ -28,7 +32,10 @@ export const Route = createFileRoute("/api/cli/exchange")({ const { orgId } = body; if (!orgId) { - return Response.json({ error: "orgId is required" }, { status: 400 }); + return Response.json( + { error: "orgId is required" }, + { status: 400 }, + ); } // Verify user token @@ -40,7 +47,10 @@ export const Route = createFileRoute("/api/cli/exchange")({ }); if (!userToken) { - return Response.json({ error: "Invalid user token" }, { status: 401 }); + return Response.json( + { error: "Invalid user token" }, + { status: 401 }, + ); } // Verify user has access to org @@ -77,7 +87,10 @@ export const Route = createFileRoute("/api/cli/exchange")({ }); } catch (error) { console.error("Token exchange error:", error); - return Response.json({ error: "Failed to exchange token" }, { status: 500 }); + return Response.json( + { error: "Failed to exchange token" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/api/cli/login.ts b/apps/web/src/routes/api/cli/login.ts index d1ed4077..263e56c1 100644 --- a/apps/web/src/routes/api/cli/login.ts +++ b/apps/web/src/routes/api/cli/login.ts @@ -2,7 +2,11 @@ import { createFileRoute } from "@tanstack/react-router"; import { db } from "../../../db"; import { cliLoginSessions } from "../../../db/auth-schema"; import { randomUUID, randomBytes } from "crypto"; -import {rateLimiters, getClientIdentifier, createRateLimitResponse,} from "../../../lib/rate-limiter"; +import { + rateLimiters, + getClientIdentifier, + createRateLimitResponse, +} from "../../../lib/rate-limiter"; export const Route = createFileRoute("/api/cli/login")({ server: { @@ -51,4 +55,3 @@ export const Route = createFileRoute("/api/cli/login")({ }, }, }); - diff --git a/apps/web/src/routes/api/cli/login/status.ts b/apps/web/src/routes/api/cli/login/status.ts index 5a9d559a..b11ee891 100644 --- a/apps/web/src/routes/api/cli/login/status.ts +++ b/apps/web/src/routes/api/cli/login/status.ts @@ -2,7 +2,11 @@ import { createFileRoute } from "@tanstack/react-router"; import { db } from "../../../../db"; import { cliLoginSessions } from "../../../../db/auth-schema"; import { eq, and, gt } from "drizzle-orm"; -import {rateLimiters, getClientIdentifier, createRateLimitResponse,} from "../../../../lib/rate-limiter"; +import { + rateLimiters, + getClientIdentifier, + createRateLimitResponse, +} from "../../../../lib/rate-limiter"; export const Route = createFileRoute("/api/cli/login/status")({ server: { @@ -45,7 +49,10 @@ export const Route = createFileRoute("/api/cli/login/status")({ return Response.json({ status: "pending" }); } catch (error) { console.error("CLI login status error:", error); - return Response.json({ error: "Failed to check status" }, { status: 500 }); + return Response.json( + { error: "Failed to check status" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/api/dashboard/validate-ws-token.ts b/apps/web/src/routes/api/dashboard/validate-ws-token.ts index 333d7ffc..37abba87 100644 --- a/apps/web/src/routes/api/dashboard/validate-ws-token.ts +++ b/apps/web/src/routes/api/dashboard/validate-ws-token.ts @@ -1,6 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; import { redis } from "../../../lib/redis"; -import { rateLimit, getClientIdentifier, createRateLimitResponse } from "../../../lib/rate-limiter"; +import { + rateLimit, + getClientIdentifier, + createRateLimitResponse, +} from "../../../lib/rate-limiter"; import { timingSafeEqual } from "crypto"; const TOKEN_PREFIX = "dashboard:ws:"; @@ -32,11 +36,17 @@ export const Route = createFileRoute("/api/dashboard/validate-ws-token")({ if (!INTERNAL_API_SECRET) { console.error("INTERNAL_API_SECRET not configured"); - return Response.json({ valid: false, error: "Server misconfigured" }, { status: 500 }); + return Response.json( + { valid: false, error: "Server misconfigured" }, + { status: 500 }, + ); } if (!secureCompare(providedSecret || "", INTERNAL_API_SECRET)) { - return Response.json({ valid: false, error: "Unauthorized" }, { status: 401 }); + return Response.json( + { valid: false, error: "Unauthorized" }, + { status: 401 }, + ); } // Rate limit by client IP (protects against brute force even with valid secret) @@ -55,27 +65,43 @@ export const Route = createFileRoute("/api/dashboard/validate-ws-token")({ const { token } = body; if (!token || typeof token !== "string" || token.length !== 64) { - return Response.json({ valid: false, error: "Invalid token" }, { status: 401 }); + return Response.json( + { valid: false, error: "Invalid token" }, + { status: 401 }, + ); } const key = `${TOKEN_PREFIX}${token}`; - + // Get and delete atomically (single-use token) const tokenData = await redis.getdel(key); if (!tokenData) { - return Response.json({ valid: false, error: "Invalid or expired token" }, { status: 401 }); + return Response.json( + { valid: false, error: "Invalid or expired token" }, + { status: 401 }, + ); } let parsedTokenData; try { parsedTokenData = JSON.parse(tokenData); } catch (parseError) { - console.error("Malformed dashboard token data in Redis for key:", key, parseError); - return Response.json({ valid: false, error: "Invalid or expired token" }, { status: 401 }); + console.error( + "Malformed dashboard token data in Redis for key:", + key, + parseError, + ); + return Response.json( + { valid: false, error: "Invalid or expired token" }, + { status: 401 }, + ); } - const { orgId, userId } = parsedTokenData as { orgId: string; userId: string }; + const { orgId, userId } = parsedTokenData as { + orgId: string; + userId: string; + }; return Response.json({ valid: true, orgId, @@ -83,7 +109,10 @@ export const Route = createFileRoute("/api/dashboard/validate-ws-token")({ }); } catch (error) { console.error("Error validating dashboard token:", error); - return Response.json({ valid: false, error: "Internal server error" }, { status: 500 }); + return Response.json( + { valid: false, error: "Internal server error" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/api/dashboard/ws-token.ts b/apps/web/src/routes/api/dashboard/ws-token.ts index 80dca387..ddbf22f0 100644 --- a/apps/web/src/routes/api/dashboard/ws-token.ts +++ b/apps/web/src/routes/api/dashboard/ws-token.ts @@ -15,7 +15,9 @@ export const Route = createFileRoute("/api/dashboard/ws-token")({ handlers: { POST: async ({ request }) => { try { - const session = await auth.api.getSession({ headers: request.headers }); + const session = await auth.api.getSession({ + headers: request.headers, + }); if (!session) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -24,7 +26,10 @@ export const Route = createFileRoute("/api/dashboard/ws-token")({ const { orgId } = body; if (!orgId) { - return Response.json({ error: "Organization ID required" }, { status: 400 }); + return Response.json( + { error: "Organization ID required" }, + { status: 400 }, + ); } // Verify user has access to this organization @@ -34,9 +39,12 @@ export const Route = createFileRoute("/api/dashboard/ws-token")({ const hasAccess = organizations.some((org) => org.id === orgId); if (!hasAccess) { - return Response.json({ error: "Access denied to organization" }, { status: 403 }); + return Response.json( + { error: "Access denied to organization" }, + { status: 403 }, + ); } - + const token = generateSecureToken(); const tokenData = JSON.stringify({ orgId, @@ -47,13 +55,16 @@ export const Route = createFileRoute("/api/dashboard/ws-token")({ `${TOKEN_PREFIX}${token}`, tokenData, "EX", - TOKEN_TTL_SECONDS + TOKEN_TTL_SECONDS, ); return Response.json({ token, expiresIn: TOKEN_TTL_SECONDS }); } catch (error) { console.error("Error generating dashboard token:", error); - return Response.json({ error: "Internal server error" }, { status: 500 }); + return Response.json( + { error: "Internal server error" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/api/tunnel/auth.ts b/apps/web/src/routes/api/tunnel/auth.ts index 15eacced..466631b4 100644 --- a/apps/web/src/routes/api/tunnel/auth.ts +++ b/apps/web/src/routes/api/tunnel/auth.ts @@ -5,7 +5,11 @@ import { authTokens, organizationSettings } from "../../../db/app-schema"; import { cliOrgTokens } from "../../../db/auth-schema"; import { subscriptions } from "../../../db/subscription-schema"; import { SUBSCRIPTION_PLANS } from "../../../lib/subscription-plans"; -import {rateLimiters, getClientIdentifier, createRateLimitResponse,} from "../../../lib/rate-limiter"; +import { + rateLimiters, + getClientIdentifier, + createRateLimitResponse, +} from "../../../lib/rate-limiter"; export const Route = createFileRoute("/api/tunnel/auth")({ server: { diff --git a/apps/web/src/routes/api/tunnel/check-subdomain.ts b/apps/web/src/routes/api/tunnel/check-subdomain.ts index b7786e19..a3bebd0f 100644 --- a/apps/web/src/routes/api/tunnel/check-subdomain.ts +++ b/apps/web/src/routes/api/tunnel/check-subdomain.ts @@ -34,7 +34,10 @@ export const Route = createFileRoute("/api/tunnel/check-subdomain")({ if (organizationId && record.organizationId === organizationId) { return Response.json({ allowed: true, type: "owned" }); } - return Response.json({ allowed: false, error: "Subdomain already taken" }); + return Response.json({ + allowed: false, + error: "Subdomain already taken", + }); } // Only check limits if explicitly requested (for subdomain reservation, not tunnel use) diff --git a/apps/web/src/routes/api/tunnel/register.ts b/apps/web/src/routes/api/tunnel/register.ts index 3faaf6e7..49228ec5 100644 --- a/apps/web/src/routes/api/tunnel/register.ts +++ b/apps/web/src/routes/api/tunnel/register.ts @@ -6,7 +6,11 @@ import { tunnels } from "../../../db/app-schema"; import { subscriptions } from "../../../db/subscription-schema"; import { getPlanLimits } from "../../../lib/subscription-plans"; import { redis } from "../../../lib/redis"; -import {rateLimiters, getClientIdentifier, createRateLimitResponse,} from "../../../lib/rate-limiter"; +import { + rateLimiters, + getClientIdentifier, + createRateLimitResponse, +} from "../../../lib/rate-limiter"; export const Route = createFileRoute("/api/tunnel/register")({ server: { @@ -42,12 +46,18 @@ export const Route = createFileRoute("/api/tunnel/register")({ } = body; if (!userId || !organizationId) { - return Response.json({ error: "Missing required fields" }, { status: 400 }); + return Response.json( + { error: "Missing required fields" }, + { status: 400 }, + ); } // For all protocols, we need url if (!url) { - return Response.json({ error: "Missing URL for tunnel" }, { status: 400 }); + return Response.json( + { error: "Missing URL for tunnel" }, + { status: 400 }, + ); } // For TCP/UDP, we also need tunnelId and remotePort @@ -132,7 +142,9 @@ export const Route = createFileRoute("/api/tunnel/register")({ `[TUNNEL LIMIT CHECK] ALLOWED - ${activeCount} < ${tunnelLimit}`, ); } else { - console.log(`[TUNNEL LIMIT CHECK] SKIPPED - Reconnection detected`); + console.log( + `[TUNNEL LIMIT CHECK] SKIPPED - Reconnection detected`, + ); } if (existingTunnel) { @@ -165,13 +177,19 @@ export const Route = createFileRoute("/api/tunnel/register")({ }); if ("error" in result) { - return Response.json({ error: result.error }, { status: result.status }); + return Response.json( + { error: result.error }, + { status: result.status }, + ); } return Response.json({ success: true, tunnelId: result.tunnelId }); } catch (error) { console.error("Tunnel registration error:", error); - return Response.json({ error: "Internal server error" }, { status: 500 }); + return Response.json( + { error: "Internal server error" }, + { status: 500 }, + ); } }, }, diff --git a/apps/web/src/routes/onboarding.tsx b/apps/web/src/routes/onboarding.tsx index deb3db09..99da07e5 100644 --- a/apps/web/src/routes/onboarding.tsx +++ b/apps/web/src/routes/onboarding.tsx @@ -71,7 +71,7 @@ function Onboarding() { setError( data.reason === "reserved" ? "This slug is reserved. Contact support@outray.dev to claim it." - : "This slug is already taken." + : "This slug is already taken.", ); } } catch (error) { diff --git a/package-lock.json b/package-lock.json index 33380bf4..97edf019 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,12 +17,14 @@ "zustand": "^5.0.9" }, "devDependencies": { + "husky": "^9.0.11", + "prettier": "^3.2.5", "typescript": "^5.7.2" } }, "apps/cli": { "name": "outray", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "dependencies": { "@iarna/toml": "^2.2.5", @@ -213,7 +215,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -264,7 +265,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -587,14 +587,12 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.0.tgz", "integrity": "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@better-fetch/fetch": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.18.tgz", - "integrity": "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==", - "peer": true + "integrity": "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==" }, "node_modules/@braintree/sanitize-url": { "version": "7.1.1", @@ -3712,7 +3710,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -5419,7 +5416,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5430,7 +5426,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5455,7 +5450,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", "license": "MIT", - "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -5546,7 +5540,6 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -5834,7 +5827,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6087,7 +6079,6 @@ "version": "1.0.19", "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.0.19.tgz", "integrity": "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw==", - "peer": true, "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", @@ -6223,7 +6214,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6727,7 +6717,6 @@ "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.4.1.tgz", "integrity": "sha512-E7WKBcHVhAVrY6JYD5kteNqVq1GSZxqGrdSiwXR9at+XHi43HJoCQKXcCczR5LBnBquFZPsB3o7HklulKoBU5w==", "license": "MIT", - "peer": true, "peerDependencies": { "srvx": ">=0.7.1" }, @@ -6788,7 +6777,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -7189,7 +7177,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7305,7 +7292,6 @@ "resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz", "integrity": "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==", "license": "MIT", - "peer": true, "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", @@ -7608,7 +7594,6 @@ "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", @@ -7898,7 +7883,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -7975,7 +7959,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8562,7 +8545,6 @@ "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.4.6.tgz", "integrity": "sha512-cPmKu7HmzzAOXk4TbAfJhVQ12C36nu0A8sDPi664X35lOAMr+vBtjY6yIYrc8szPEFrBcmkVRGLZyEkNDZWE/Q==", "license": "MIT", - "peer": true, "dependencies": { "@formatjs/intl-localematcher": "^0.7.5", "@orama/orama": "^3.1.18", @@ -9734,6 +9716,22 @@ "node": ">= 6" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -9799,7 +9797,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -10068,7 +10065,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -10091,7 +10087,6 @@ "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -10236,7 +10231,6 @@ "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.9.tgz", "integrity": "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" } @@ -10621,7 +10615,6 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "license": "ISC", - "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -10631,7 +10624,6 @@ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.560.0.tgz", "integrity": "sha512-NwKoUA/aBShsdL8WE5lukV2F/tjHzQRlonQs7fkNGI1sCT0Ay4a9Ap3ST2clUUkcY+9eQ0pBe2hybTQd2fmyDA==", "license": "ISC", - "peer": true, "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -12289,8 +12281,7 @@ "version": "2.0.0-alpha.3", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-2.0.0-alpha.3.tgz", "integrity": "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ohash": { "version": "2.0.11", @@ -12611,7 +12602,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -12893,7 +12883,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13241,7 +13230,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13251,7 +13239,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13296,7 +13283,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -13590,8 +13576,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -13981,7 +13966,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.2.tgz", "integrity": "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -14493,8 +14477,7 @@ "version": "0.182.0", "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -14587,7 +14570,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15328,7 +15310,6 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -15887,7 +15868,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16282,7 +16262,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16851,7 +16830,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17155,7 +17133,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index a420a226..058d22b9 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,15 @@ "install:all": "npm install && cd apps/tunnel && npm install && cd ../cli && npm install && cd ../web && npm install", "dev:tunnel": "cd apps/tunnel && npm run dev", "dev:web": "cd apps/web && npm run dev", - "build": "cd apps/tunnel && npm run build && cd ../cli && npm run build && cd ../web && npm run build" + "build": "cd apps/tunnel && npm run build && cd ../cli && npm run build && cd ../web && npm run build", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,mdx,css,html}\"", + "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,mdx,css,html}\"", + "prepare": "husky install" }, "devDependencies": { - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "prettier": "^3.2.5", + "husky": "^9.0.11" }, "dependencies": { "axios": "^1.13.2", diff --git a/shared/reserved-slugs.ts b/shared/reserved-slugs.ts index 54c40cab..cf19ca74 100644 --- a/shared/reserved-slugs.ts +++ b/shared/reserved-slugs.ts @@ -17,7 +17,7 @@ export const RESERVED_SLUGS = [ "callback", "oauth", "sso", - + // Account & user management "account", "accounts", @@ -28,7 +28,7 @@ export const RESERVED_SLUGS = [ "preferences", "dashboard", "onboarding", - + // Organization-related "org", "orgs", @@ -38,7 +38,7 @@ export const RESERVED_SLUGS = [ "teams", "workspace", "workspaces", - + // Billing & subscriptions "billing", "subscription", @@ -50,7 +50,7 @@ export const RESERVED_SLUGS = [ "payments", "invoice", "invoices", - + // Support & help "help", "support", @@ -59,7 +59,7 @@ export const RESERVED_SLUGS = [ "faq", "contact", "feedback", - + // Legal & compliance "terms", "privacy", @@ -68,7 +68,7 @@ export const RESERVED_SLUGS = [ "compliance", "gdpr", "cookies", - + // Marketing & public pages "about", "blog", @@ -82,7 +82,7 @@ export const RESERVED_SLUGS = [ "features", "home", "landing", - + // Product & branding "outray", "tunnel", @@ -93,7 +93,7 @@ export const RESERVED_SLUGS = [ "status", "health", "healthcheck", - + // Infrastructure & technical "www", "mail", @@ -108,7 +108,7 @@ export const RESERVED_SLUGS = [ "uploads", "download", "downloads", - + // Common reserved words "root", "system", @@ -125,7 +125,7 @@ export const RESERVED_SLUGS = [ "production", "dev", "development", - + // Webhooks & integrations "webhook", "webhooks", @@ -133,7 +133,7 @@ export const RESERVED_SLUGS = [ "integrations", "connect", "callback", - + // Misc reserved "new", "create", @@ -159,7 +159,7 @@ export const RESERVED_SLUGS = [ "audit", "cli", "cron", - + // Programming languages "javascript", "typescript", @@ -206,7 +206,7 @@ export const RESERVED_SLUGS = [ "graphql", "wasm", "webassembly", - + // Frontend frameworks & libraries "react", "reactjs", @@ -230,7 +230,7 @@ export const RESERVED_SLUGS = [ "stencil", "qwik", "astro", - + // Backend frameworks "express", "expressjs", @@ -276,7 +276,7 @@ export const RESERVED_SLUGS = [ "redwoodjs", "blitz", "blitzjs", - + // Mobile frameworks "flutter", "reactnative", @@ -291,7 +291,7 @@ export const RESERVED_SLUGS = [ "compose", "nativescript", "expo", - + // CSS frameworks & tools "tailwind", "tailwindcss", @@ -320,7 +320,7 @@ export const RESERVED_SLUGS = [ "shadcn", "daisyui", "headlessui", - + // Build tools & bundlers "webpack", "vite", @@ -343,7 +343,7 @@ export const RESERVED_SLUGS = [ "npm", "yarn", "pnpm", - + // Testing frameworks "jest", "mocha", @@ -371,7 +371,7 @@ export const RESERVED_SLUGS = [ "supertest", "storybook", "chromatic", - + // Databases "postgres", "postgresql", @@ -416,7 +416,7 @@ export const RESERVED_SLUGS = [ "tigerdata", "clickhouse", "duckdb", - + // Cloud & infrastructure "aws", "amazon", @@ -457,7 +457,7 @@ export const RESERVED_SLUGS = [ "consul", "vault", "nomad", - + // DevOps & CI/CD "github", "gitlab", @@ -477,7 +477,7 @@ export const RESERVED_SLUGS = [ "octopus", "flux", "fluxcd", - + // API & communication "rest", "restful", @@ -512,7 +512,7 @@ export const RESERVED_SLUGS = [ "langchain", "llamaindex", "huggingface", - + // State management "redux", "mobx", @@ -527,7 +527,7 @@ export const RESERVED_SLUGS = [ "akita", "effector", "nanostores", - + // Auth & identity "oauth", "oauth2", @@ -544,7 +544,7 @@ export const RESERVED_SLUGS = [ "keycloak", "cognito", "firebase-auth", - + // Monitoring & observability "datadog", "newrelic", @@ -570,7 +570,7 @@ export const RESERVED_SLUGS = [ "matomo", "pagerduty", "opsgenie", - + // SDKs & platforms "sdk", "sdks", diff --git a/test-server/server.js b/test-server/server.js index ac8062fa..a6f8287e 100644 --- a/test-server/server.js +++ b/test-server/server.js @@ -1,4 +1,4 @@ -const http = require('http'); +const http = require("http"); const PORT = 4800; @@ -8,161 +8,223 @@ const server = http.createServer((req, res) => { const method = req.method; // Collect body for POST/PUT/PATCH - let body = ''; - req.on('data', chunk => body += chunk); - req.on('end', () => { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { console.log(`${method} ${path}`); // CORS headers - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Custom-Header'); - - if (method === 'OPTIONS') { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader( + "Access-Control-Allow-Methods", + "GET, POST, PUT, PATCH, DELETE, OPTIONS", + ); + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, Authorization, X-Custom-Header", + ); + + if (method === "OPTIONS") { res.writeHead(204); return res.end(); } // Routes - if (path === '/' && method === 'GET') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify({ message: 'Welcome to test server', timestamp: Date.now() })); - } - - if (path === '/users' && method === 'GET') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify([ - { id: 1, name: 'Alice', email: 'alice@example.com' }, - { id: 2, name: 'Bob', email: 'bob@example.com' }, - { id: 3, name: 'Charlie', email: 'charlie@example.com' } - ])); - } - - if (path === '/users' && method === 'POST') { + if (path === "/" && method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end( + JSON.stringify({ + message: "Welcome to test server", + timestamp: Date.now(), + }), + ); + } + + if (path === "/users" && method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end( + JSON.stringify([ + { id: 1, name: "Alice", email: "alice@example.com" }, + { id: 2, name: "Bob", email: "bob@example.com" }, + { id: 3, name: "Charlie", email: "charlie@example.com" }, + ]), + ); + } + + if (path === "/users" && method === "POST") { const data = body ? JSON.parse(body) : {}; - res.writeHead(201, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify({ id: 4, ...data, createdAt: new Date().toISOString() })); - } - - if (path.match(/^\/users\/\d+$/) && method === 'GET') { - const id = path.split('/')[2]; - res.writeHead(200, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify({ id: parseInt(id), name: 'User ' + id, email: `user${id}@example.com` })); - } - - if (path.match(/^\/users\/\d+$/) && method === 'PUT') { - const id = path.split('/')[2]; + res.writeHead(201, { "Content-Type": "application/json" }); + return res.end( + JSON.stringify({ id: 4, ...data, createdAt: new Date().toISOString() }), + ); + } + + if (path.match(/^\/users\/\d+$/) && method === "GET") { + const id = path.split("/")[2]; + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end( + JSON.stringify({ + id: parseInt(id), + name: "User " + id, + email: `user${id}@example.com`, + }), + ); + } + + if (path.match(/^\/users\/\d+$/) && method === "PUT") { + const id = path.split("/")[2]; const data = body ? JSON.parse(body) : {}; - res.writeHead(200, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify({ id: parseInt(id), ...data, updatedAt: new Date().toISOString() })); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end( + JSON.stringify({ + id: parseInt(id), + ...data, + updatedAt: new Date().toISOString(), + }), + ); } - if (path.match(/^\/users\/\d+$/) && method === 'DELETE') { + if (path.match(/^\/users\/\d+$/) && method === "DELETE") { res.writeHead(204); return res.end(); } - if (path === '/posts' && method === 'GET') { - const page = url.searchParams.get('page') || '1'; - const limit = url.searchParams.get('limit') || '10'; - res.writeHead(200, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify({ - data: [ - { id: 1, title: 'First Post', body: 'Content here...' }, - { id: 2, title: 'Second Post', body: 'More content...' } - ], - pagination: { page: parseInt(page), limit: parseInt(limit), total: 100 } - })); - } - - if (path === '/upload' && method === 'POST') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify({ success: true, size: body.length, message: 'File received' })); - } - - if (path === '/echo' && method === 'POST') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify({ echo: body ? JSON.parse(body) : null, headers: req.headers })); - } - - if (path === '/slow' && method === 'GET') { + if (path === "/posts" && method === "GET") { + const page = url.searchParams.get("page") || "1"; + const limit = url.searchParams.get("limit") || "10"; + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end( + JSON.stringify({ + data: [ + { id: 1, title: "First Post", body: "Content here..." }, + { id: 2, title: "Second Post", body: "More content..." }, + ], + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total: 100, + }, + }), + ); + } + + if (path === "/upload" && method === "POST") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end( + JSON.stringify({ + success: true, + size: body.length, + message: "File received", + }), + ); + } + + if (path === "/echo" && method === "POST") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end( + JSON.stringify({ + echo: body ? JSON.parse(body) : null, + headers: req.headers, + }), + ); + } + + if (path === "/slow" && method === "GET") { setTimeout(() => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ message: 'Slow response', delay: 2000 })); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ message: "Slow response", delay: 2000 })); }, 2000); return; } - if (path === '/error/400') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify({ error: 'Bad Request', message: 'Invalid input' })); + if (path === "/error/400") { + res.writeHead(400, { "Content-Type": "application/json" }); + return res.end( + JSON.stringify({ error: "Bad Request", message: "Invalid input" }), + ); } - if (path === '/error/401') { - res.writeHead(401, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify({ error: 'Unauthorized', message: 'Authentication required' })); + if (path === "/error/401") { + res.writeHead(401, { "Content-Type": "application/json" }); + return res.end( + JSON.stringify({ + error: "Unauthorized", + message: "Authentication required", + }), + ); } - if (path === '/error/403') { - res.writeHead(403, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify({ error: 'Forbidden', message: 'Access denied' })); + if (path === "/error/403") { + res.writeHead(403, { "Content-Type": "application/json" }); + return res.end( + JSON.stringify({ error: "Forbidden", message: "Access denied" }), + ); } - if (path === '/error/404') { - res.writeHead(404, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify({ error: 'Not Found', message: 'Resource not found' })); + if (path === "/error/404") { + res.writeHead(404, { "Content-Type": "application/json" }); + return res.end( + JSON.stringify({ error: "Not Found", message: "Resource not found" }), + ); } - if (path === '/error/500') { - res.writeHead(500, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify({ error: 'Internal Server Error', message: 'Something went wrong' })); + if (path === "/error/500") { + res.writeHead(500, { "Content-Type": "application/json" }); + return res.end( + JSON.stringify({ + error: "Internal Server Error", + message: "Something went wrong", + }), + ); } - if (path === '/html' && method === 'GET') { - res.writeHead(200, { 'Content-Type': 'text/html' }); - return res.end('

Hello HTML

This is HTML content

'); + if (path === "/html" && method === "GET") { + res.writeHead(200, { "Content-Type": "text/html" }); + return res.end( + "

Hello HTML

This is HTML content

", + ); } - if (path === '/text' && method === 'GET') { - res.writeHead(200, { 'Content-Type': 'text/plain' }); - return res.end('Plain text response\nLine 2\nLine 3'); + if (path === "/text" && method === "GET") { + res.writeHead(200, { "Content-Type": "text/plain" }); + return res.end("Plain text response\nLine 2\nLine 3"); } - if (path === '/headers' && method === 'GET') { + if (path === "/headers" && method === "GET") { res.writeHead(200, { - 'Content-Type': 'application/json', - 'X-Custom-Header': 'custom-value', - 'X-Request-Id': 'req-' + Date.now(), - 'Cache-Control': 'no-cache' + "Content-Type": "application/json", + "X-Custom-Header": "custom-value", + "X-Request-Id": "req-" + Date.now(), + "Cache-Control": "no-cache", }); return res.end(JSON.stringify({ receivedHeaders: req.headers })); } // 404 fallback - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Not Found', path })); + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not Found", path })); }); }); server.listen(PORT, () => { console.log(`Test server running on http://localhost:${PORT}`); - console.log('\nAvailable endpoints:'); - console.log(' GET / - Welcome message'); - console.log(' GET /users - List users'); - console.log(' POST /users - Create user'); - console.log(' GET /users/:id - Get user'); - console.log(' PUT /users/:id - Update user'); - console.log(' DELETE /users/:id - Delete user'); - console.log(' GET /posts?page=&limit= - Paginated posts'); - console.log(' POST /upload - Upload endpoint'); - console.log(' POST /echo - Echo request body'); - console.log(' GET /slow - 2s delayed response'); - console.log(' GET /error/400 - Bad Request'); - console.log(' GET /error/401 - Unauthorized'); - console.log(' GET /error/403 - Forbidden'); - console.log(' GET /error/404 - Not Found'); - console.log(' GET /error/500 - Server Error'); - console.log(' GET /html - HTML response'); - console.log(' GET /text - Plain text response'); - console.log(' GET /headers - Custom headers'); + console.log("\nAvailable endpoints:"); + console.log(" GET / - Welcome message"); + console.log(" GET /users - List users"); + console.log(" POST /users - Create user"); + console.log(" GET /users/:id - Get user"); + console.log(" PUT /users/:id - Update user"); + console.log(" DELETE /users/:id - Delete user"); + console.log(" GET /posts?page=&limit= - Paginated posts"); + console.log(" POST /upload - Upload endpoint"); + console.log(" POST /echo - Echo request body"); + console.log(" GET /slow - 2s delayed response"); + console.log(" GET /error/400 - Bad Request"); + console.log(" GET /error/401 - Unauthorized"); + console.log(" GET /error/403 - Forbidden"); + console.log(" GET /error/404 - Not Found"); + console.log(" GET /error/500 - Server Error"); + console.log(" GET /html - HTML response"); + console.log(" GET /text - Plain text response"); + console.log(" GET /headers - Custom headers"); });