Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.git
.github
.next
node_modules
npm-debug.log*
.env
.env.*
!.env.local.example
Dockerfile
README.take3.md
9 changes: 2 additions & 7 deletions .env.local.example
Original file line number Diff line number Diff line change
@@ -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=
41 changes: 41 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
39 changes: 39 additions & 0 deletions README.take3.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
turbopack: {
root: process.cwd(),
},
};

export default nextConfig;
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 80 additions & 0 deletions scripts/check-browser-secrets.mjs
Original file line number Diff line number Diff line change
@@ -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.");
5 changes: 1 addition & 4 deletions src/components/dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ const KIND_COLORS: Record<string, string> = {
contract: 'var(--k-contract)',
package: 'var(--k-package)',
}

const LANG_COLORS: Record<string, string> = {
go: 'oklch(0.72 0.12 215)',
dart: 'oklch(0.72 0.12 240)',
Expand Down Expand Up @@ -63,7 +62,6 @@ function Kpi({
</div>
)
}

function RepoCard({ r }: { r: Repo }) {
const kinds = [
{ label: 'functions', value: r.funcs, color: 'var(--k-function)' },
Expand Down Expand Up @@ -350,7 +348,7 @@ export function Dashboard() {
</div>
<div style={{ padding: 22, color: 'var(--fg-2)', fontSize: 13 }}>
Make sure the gortex server is running on{' '}
<span className="mono">{process.env.NEXT_PUBLIC_GORTEX_URL || 'http://localhost:4747'}</span>.
<span className="mono">{process.env.NEXT_PUBLIC_GORTEX_URL || 'same origin'}</span>.
</div>
</>
)
Expand Down Expand Up @@ -572,4 +570,3 @@ export function Dashboard() {
</>
)
}

19 changes: 5 additions & 14 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
const res = await fetch(`${SERVER_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...authHeaders(),
...options?.headers,
},
})
Expand Down Expand Up @@ -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
Expand Down