diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4882911 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +.next +node_modules +npm-debug.log* +.env +.env.* +!.env.local.example +Dockerfile +README.take3.md diff --git a/.env.local.example b/.env.local.example index 904f0e9..0ffe457 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,9 +1,4 @@ -# Gortex web — local development config. Copy to .env.local. +# Gortex web - local development config. Copy to .env.local. # -# Base URL of the gortex server. Defaults to http://localhost:4747. +# Base URL of the gortex server. Defaults to same-origin when unset. # NEXT_PUBLIC_GORTEX_URL=http://localhost:4747 -# -# Bearer token, only needed when the server runs with --auth-token. -# On localhost-only (the default) the server is unauthenticated and -# this can stay unset. -# NEXT_PUBLIC_GORTEX_TOKEN= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..76034fa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM node:22-bookworm-slim AS deps + +WORKDIR /app + +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +FROM deps AS build + +ARG NEXT_PUBLIC_GORTEX_URL=https://gortex.take3tech.dev +ENV NEXT_PUBLIC_GORTEX_URL=${NEXT_PUBLIC_GORTEX_URL} +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY . . +RUN npm run typecheck \ + && npm run build \ + && npm run check:browser-secrets + +FROM node:22-bookworm-slim AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 + +RUN groupadd --gid 65532 gortexweb \ + && useradd --uid 65532 --gid 65532 --home-dir /app --no-create-home --shell /usr/sbin/nologin gortexweb + +COPY --from=build --chown=65532:65532 /app/public ./public +COPY --from=build --chown=65532:65532 /app/.next/standalone ./ +COPY --from=build --chown=65532:65532 /app/.next/static ./.next/static + +USER 65532:65532 + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/README.take3.md b/README.take3.md new file mode 100644 index 0000000..3838038 --- /dev/null +++ b/README.take3.md @@ -0,0 +1,39 @@ +# Take Three Deployment Notes + +This fork contains the approved Take Three deployment patch for the Gortex web +UI. It is deployed as a developer-facing browser UI for the shared EKS-hosted +Gortex codegraph service. + +## Runtime + +- Public UI origin: `https://gortex.take3tech.dev` +- Backend API origin: same-origin `/v1/*` and `/v1/events` +- Gortex backend version: `v0.47.0` +- Indexed repositories: `takethree/loanmaps` and `takethree/infrastructure` +- Access boundary: Cloudflare Access Development Team policy +- MCP upstream boundary: `gortex.mcp.take3tech.dev` remains service-token-only + +## Build + +Use Node.js 22 for local and CI builds. The upstream package does not declare an +`engines` field, so the infrastructure workflow and Dockerfile pin the build +runtime instead. + +```powershell +npm ci --ignore-scripts +$env:NEXT_PUBLIC_GORTEX_URL = "https://gortex.take3tech.dev" +npm run typecheck +npm run build +npm run check:browser-secrets +``` + +Production images are built from pinned commits in `takethree/gortex-web` with +`npm ci --ignore-scripts` and the checked-in lockfile. Runtime pods must not +clone source code or install application dependencies at startup. + +## Credential Guardrail + +Do not configure `NEXT_PUBLIC_GORTEX_TOKEN`, `CF-Access-Client-Id`, +`CF-Access-Client-Secret`, or equivalent backend bearer credentials for this UI. +The UI and API are intentionally same-origin behind Cloudflare Access, and the +browser must not receive service-token material. diff --git a/next.config.ts b/next.config.ts index e9ffa30..cd8f14f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", + turbopack: { + root: process.cwd(), + }, }; export default nextConfig; diff --git a/package.json b/package.json index e99cf22..8bea727 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "check:browser-secrets": "node scripts/check-browser-secrets.mjs" }, "dependencies": { "@base-ui/react": "^1.4.1", diff --git a/scripts/check-browser-secrets.mjs b/scripts/check-browser-secrets.mjs new file mode 100644 index 0000000..53c9e91 --- /dev/null +++ b/scripts/check-browser-secrets.mjs @@ -0,0 +1,80 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const roots = [ + ".next/static", + ".next/server/app", + ".next/standalone/.next/static", + ".next/standalone/.next/server/app", +].filter(existsSync); + +const forbidden = [ + "NEXT_PUBLIC_GORTEX_TOKEN", + "CF-Access-Client-Id", + "CF-Access-Client-Secret", + "CF_ACCESS_CLIENT_ID", + "CF_ACCESS_CLIENT_SECRET", + "GORTEX_SERVER_TOKEN", + "GORTEX_DAEMON_HTTP_TOKEN", +]; + +const extensions = new Set([ + ".html", + ".js", + ".mjs", + ".json", + ".txt", + ".css", + ".map", +]); + +function walk(dir) { + const entries = readdirSync(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...walk(path)); + } else if (entry.isFile()) { + const dot = entry.name.lastIndexOf("."); + const extension = dot >= 0 ? entry.name.slice(dot) : ""; + if (extensions.has(extension)) { + files.push(path); + } + } + } + + return files; +} + +if (roots.length === 0) { + throw new Error("No Next.js build output found. Run `npm run build` first."); +} + +const findings = []; + +for (const root of roots) { + for (const file of walk(root)) { + if (statSync(file).size > 10 * 1024 * 1024) { + continue; + } + + const text = readFileSync(file, "utf8"); + for (const marker of forbidden) { + if (text.includes(marker)) { + findings.push(`${file}: ${marker}`); + } + } + } +} + +if (findings.length > 0) { + console.error("Forbidden browser-delivered credential markers found:"); + for (const finding of findings) { + console.error(`- ${finding}`); + } + process.exit(1); +} + +console.log("No forbidden browser-delivered credential markers found."); diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx index 9b72f9a..a5dd0ef 100644 --- a/src/components/dashboard/Dashboard.tsx +++ b/src/components/dashboard/Dashboard.tsx @@ -21,7 +21,6 @@ const KIND_COLORS: Record = { contract: 'var(--k-contract)', package: 'var(--k-package)', } - const LANG_COLORS: Record = { go: 'oklch(0.72 0.12 215)', dart: 'oklch(0.72 0.12 240)', @@ -63,7 +62,6 @@ function Kpi({ ) } - function RepoCard({ r }: { r: Repo }) { const kinds = [ { label: 'functions', value: r.funcs, color: 'var(--k-function)' }, @@ -350,7 +348,7 @@ export function Dashboard() {
Make sure the gortex server is running on{' '} - {process.env.NEXT_PUBLIC_GORTEX_URL || 'http://localhost:4747'}. + {process.env.NEXT_PUBLIC_GORTEX_URL || 'same origin'}.
) @@ -572,4 +570,3 @@ export function Dashboard() { ) } - diff --git a/src/lib/api.ts b/src/lib/api.ts index e38845a..659e2fa 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,25 +7,17 @@ import type { DashboardSnapshot, KindCount, LanguageCount, ContractValidation, } from './schema' -// Single base URL for the gortex server (http://.../v1/*). -const SERVER_URL = process.env.NEXT_PUBLIC_GORTEX_URL +// Single base URL for the gortex server. Empty means same-origin, which is the +// shared Take Three deployment shape behind Cloudflare Access. +const SERVER_URL = (process.env.NEXT_PUBLIC_GORTEX_URL || process.env.NEXT_PUBLIC_GORTEX_WEB_URL - || 'http://localhost:4747' - -// Optional bearer token. Required when the server was started with -// --auth-token / $GORTEX_SERVER_TOKEN; otherwise leave unset. -const AUTH_TOKEN = process.env.NEXT_PUBLIC_GORTEX_TOKEN || '' - -function authHeaders(): HeadersInit { - return AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {} -} + || '').replace(/\/$/, '') async function serverFetch(path: string, options?: RequestInit): Promise { const res = await fetch(`${SERVER_URL}${path}`, { ...options, headers: { 'Content-Type': 'application/json', - ...authHeaders(), ...options?.headers, }, }) @@ -278,8 +270,7 @@ export const api = { // --- SSE for live activity --- subscribeEvents: (callback: (event: GraphChangeEvent) => void): EventSource => { - const qs = AUTH_TOKEN ? `?token=${encodeURIComponent(AUTH_TOKEN)}` : '' - const es = new EventSource(`${SERVER_URL}/v1/events${qs}`) + const es = new EventSource(`${SERVER_URL}/v1/events`) es.addEventListener('graph_change', (e) => { try { const data = JSON.parse(e.data) as GraphChangeEvent