diff --git a/.github/workflows/probe-live-site.yml b/.github/workflows/probe-live-site.yml new file mode 100644 index 0000000..5d93b9c --- /dev/null +++ b/.github/workflows/probe-live-site.yml @@ -0,0 +1,57 @@ +# Diagnostic: identifies which build each public host is serving, using the +# sign-in page copy as a version marker ("Operator sign-in" = pre-team-auth +# code, "Workspace sign-in" = current main). Manually triggered only. +name: probe-live-site +on: workflow_dispatch + +jobs: + probe: + runs-on: ubuntu-latest + steps: + - name: Recent deployments and their outcomes + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + curl -s -H "Authorization: Bearer $GH_TOKEN" \ + "https://api.github.com/repos/$REPO/deployments?per_page=12" -o /tmp/deps.json + echo "created_at | environment | commit | latest_status" + jq -r '.[] | [.id, .environment, (.sha[0:7]), .created_at] | @tsv' /tmp/deps.json | + while IFS=$'\t' read -r id env sha created; do + state=$(curl -s -H "Authorization: Bearer $GH_TOKEN" \ + "https://api.github.com/repos/$REPO/deployments/$id/statuses?per_page=1" | jq -r '.[0].state // "no-status"') + echo "$created | $env | $sha | $state" + done + + - name: Vercel platform status + run: | + curl -s --max-time 15 "https://www.vercel-status.com/api/v2/summary.json" | + jq -r '"overall: " + .status.description, (.incidents[]? | "INCIDENT: " + .name + " [" + .status + "]"), (.components[]? | select(.status != "operational") | "component degraded: " + .name + " = " + .status)' \ + || echo "couldn't fetch vercel status" + + - name: Auth health on production + run: | + echo "GET https://ghimtech.org/api/health/auth" + curl -s --max-time 20 "https://ghimtech.org/api/health/auth" | head -c 500; echo + + - name: Identify served version per host + run: | + for url in \ + "https://ghimtech.org/sign-in" \ + "https://www.ghimtech.org/sign-in" \ + "https://ghim-tech.vercel.app/sign-in" \ + "https://ghim-tech-reyghim1093-5928s-projects.vercel.app/sign-in"; do + echo "====================" + echo "URL: $url" + code=$(curl -sL -o /tmp/page.html -w '%{http_code}' --max-time 20 "$url" || echo "FETCH_FAILED") + echo "HTTP: $code" + grep -o "Workspace sign-in · team access" /tmp/page.html | head -1 && echo "--> NEW code (post-merge)" || true + grep -o "Operator sign-in · restricted access" /tmp/page.html | head -1 && echo "--> OLD code (pre-merge)" || true + if ! grep -qE "Workspace sign-in|Operator sign-in" /tmp/page.html; then + echo "--> no marker found (different app or error page); first 300 chars:" + head -c 300 /tmp/page.html; echo + fi + done + echo "====================" + echo "ghimtech.org response headers:" + curl -sI --max-time 20 https://ghimtech.org | head -12 diff --git a/package-lock.json b/package-lock.json index 03d8cfb..16c03d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^3.9.0", + "@monaco-editor/react": "^4.7.0", "@prisma/client": "^5.20.0", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", @@ -116,6 +117,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -132,6 +134,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -148,6 +151,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -164,6 +168,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -180,6 +185,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -196,6 +202,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -212,6 +219,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -228,6 +236,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -244,6 +253,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -260,6 +270,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -276,6 +287,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -292,6 +304,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -308,6 +321,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -324,6 +338,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -340,6 +355,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -356,6 +372,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -372,6 +389,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -388,6 +406,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -404,6 +423,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -420,6 +440,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -436,6 +457,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -452,6 +474,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -468,6 +491,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -484,6 +508,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -500,6 +525,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -516,6 +542,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1283,6 +1310,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2815,6 +2865,14 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.4", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", @@ -4422,6 +4480,16 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6296,6 +6364,19 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6351,6 +6432,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/motion-dom": { "version": "11.18.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", @@ -7720,6 +7812,12 @@ "dev": true, "license": "MIT" }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", diff --git a/package.json b/package.json index 6d0c977..8120b12 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "prisma generate && next build", "start": "next start", "lint": "eslint .", + "test": "node --import tsx --test tests/*.test.ts", "type-check": "tsc --noEmit", "db:push": "prisma db push", "db:migrate": "prisma migrate dev", @@ -19,6 +20,7 @@ }, "dependencies": { "@hookform/resolvers": "^3.9.0", + "@monaco-editor/react": "^4.7.0", "@prisma/client": "^5.20.0", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", diff --git a/pricing.html b/pricing.html new file mode 100644 index 0000000..2390139 --- /dev/null +++ b/pricing.html @@ -0,0 +1,63 @@ + + + + + + Pricing - GhimTech + + + + +
+

Choose Your Plan

+

Flexible pricing designed for every stage of your growth.

+
+
+
+

Starter

+

$9/mo

+ + Get Started +
+
+

Pro

+

$29/mo

+ + Upgrade Now +
+
+

Enterprise

+

$99/mo

+ + Contact Sales +
+
+ + \ No newline at end of file diff --git a/src/app/api/copilot/chat/route.ts b/src/app/api/copilot/chat/route.ts index b6108eb..5679703 100644 --- a/src/app/api/copilot/chat/route.ts +++ b/src/app/api/copilot/chat/route.ts @@ -3,8 +3,10 @@ * POST → agentic chat with GPT-5 (OpenAI Responses API). The copilot can * browse the web (built-in web_search) and call function tools that * act on the operator's tasks, projects, notes, and linked repos. - * Body: { messages: [{role, content}] }. - * Returns { ok, data: { text, actions: [{tool, label}] } }. + * Body: { messages: [{role, content, attachments?}] }. + * Attachments (photos as data URLs, PDFs, text files) ride on the + * latest user message and are passed to the model as vision/file + * input. Returns { ok, data: { text, actions: [{tool, label}] } }. * * Operator-only, rate-limited. Non-streaming (an agent turn may run several * tool round-trips). @@ -16,18 +18,56 @@ import { ok, apiErrors } from "@/lib/api-response"; import { rateLimit } from "@/lib/rate-limit"; import { getOpenAI, OPENAI_MODEL } from "@/lib/openai"; import { COPILOT_TOOLS, executeTool, toolLabel } from "@/lib/copilot-tools"; +import { isValidRepoName } from "@/lib/repo-files"; import { z } from "zod"; export const runtime = "nodejs"; export const maxDuration = 120; +const AttachmentSchema = z.object({ + name: z.string().min(1).max(200), + kind: z.enum(["image", "pdf", "text"]), + // Photos and PDFs travel as base64 data URLs; text files arrive pre-read. + dataUrl: z.string().max(4_000_000).optional(), + text: z.string().max(80_000).optional(), +}); + const ChatSchema = z.object({ messages: z - .array(z.object({ role: z.enum(["user", "assistant"]), content: z.string().min(1).max(8000) })) + .array( + z.object({ + role: z.enum(["user", "assistant"]), + content: z.string().min(1).max(8000), + attachments: z.array(AttachmentSchema).max(4).optional(), + }), + ) .min(1) .max(40), + mode: z.enum(["ops", "builder"]).optional(), + repo: z.string().max(140).optional(), }); +type Attachment = z.infer; + +type ContentPart = + | { type: "input_text"; text: string } + | { type: "input_image"; image_url: string; detail: "auto" } + | { type: "input_file"; filename: string; file_data: string }; + +function attachmentParts(attachments: Attachment[]): ContentPart[] { + const parts: ContentPart[] = []; + for (const a of attachments) { + if (a.kind === "image" && a.dataUrl?.startsWith("data:image/")) { + parts.push({ type: "input_image", image_url: a.dataUrl, detail: "auto" }); + } else if (a.kind === "pdf" && a.dataUrl?.startsWith("data:application/pdf")) { + parts.push({ type: "input_file", filename: a.name, file_data: a.dataUrl }); + } else if (a.kind === "text" && a.text) { + parts.push({ type: "input_text", text: `--- Attached file: ${a.name} ---\n${a.text}` }); + } + } + return parts; +} + export async function POST(req: Request) { const user = await requireUser(); @@ -50,7 +90,7 @@ export async function POST(req: Request) { const [projects, tasks] = await Promise.all([ prisma.project.findMany({ where: { userId: user.id, status: { not: "ARCHIVED" } }, - select: { name: true, slug: true, status: true, progress: true }, + select: { name: true, slug: true, status: true, progress: true, sourceRepo: true }, orderBy: { updatedAt: "desc" }, take: 20, }), @@ -62,23 +102,65 @@ export async function POST(req: Request) { }), ]); - const instructions = + const linkedRepos = Array.from( + new Set(projects.map((p) => p.sourceRepo).filter((r): r is string => Boolean(r))), + ); + + const targetRepo = + parsed.data.mode === "builder" && parsed.data.repo && isValidRepoName(parsed.data.repo) + ? parsed.data.repo + : null; + + const builderInstructions = + "You are GCODE — the operator's app-builder studio. Your single job: turn descriptions (and attached screenshots/files) into WORKING CODE, shipped. " + + "EVERY build request follows the same loop: write the complete file set with build_app_files (full file contents, sensible stack — static index.html/CSS/JS for simple pages, Vite or Next.js for real apps), ALWAYS include prTitle so a PR opens, then reply in 2-4 lines: what you built, the PR link, and that the live preview URL appears on the PR in ~2 minutes (Vercel-connected repos). " + + "Iterations ('make the hero darker', 'add a pricing section') go to the SAME branch so they land on the same PR. " + + "NEVER reply with tutorials, setup steps, code blocks to paste, or 'would you like me to' offers — you have hands; use them. NEVER ask about branch names; build_app_files resolves the default branch itself. " + + "If a repo is inaccessible, call list_github_repos, say which repos ARE available, and relay the tool's fix instructions verbatim. " + + "If something needs a secret (API keys etc.), wire the code to read it from env and tell the operator in ONE line which env var to add where. " + + "Ask at most ONE clarifying question, and only when the request is truly ambiguous — default to building with sensible choices.\n\n" + + "--- LIVE CONTEXT ---\n" + + `Operator: ${user.firstName ?? "the founder"}.\n` + + `Build target: ${targetRepo ?? "not selected — call list_github_repos and confirm with the operator"}\n` + + `Other known repos: ${linkedRepos.join(", ") || "use list_github_repos"}`; + + const opsInstructions = "You are the founder's operations copilot inside their dashboard. Be direct, concrete, and genuinely useful. " + - "You can BROWSE THE WEB (web_search) and CALL TOOLS that act on the operator's real data (list/create tasks, update task status, list projects, create notes, read a project's linked GitHub repo). " + - "Use tools when they help — actually create/update things when asked, search the web for current info, read the repo when asked about a project's direction. After acting, confirm what you did in one line. Reference projects/tasks by name. Keep replies tight unless asked to expand.\n\n" + + "You can BROWSE THE WEB (web_search) and CALL TOOLS that act on the operator's real data (list/create tasks, update task status, list projects, create notes, read a project's linked GitHub repo and its live activity). " + + "HARD RULE — ACT, DON'T INSTRUCT: you have hands. When the operator asks for an app, a feature, a fix, or any code, you MUST do the work yourself by calling build_app_files (complete file contents, sensible stack — static index.html for tiny things, Vite/Next for real apps) and reply with the PR link. " + + "NEVER reply with setup steps, 'create this file', 'run npm install', code blocks for the operator to paste, or offers like 'would you like me to...'. If you wrote code in your head, it belongs in build_app_files, not in chat. " + + "Vercel-connected repos get a live preview URL on the PR automatically — mention it. Reuse the same branch for follow-up fixes so they land on the same PR. " + + "If you need a push target and none is obvious, call list_github_repos and pick or offer from REAL entries. NEVER ask the operator for branch names — build_app_files resolves the default branch itself. " + + "If a repo errors as inaccessible, call list_github_repos, tell the operator which repos ARE available, and relay the tool's fix instructions verbatim — do not invent troubleshooting steps. " + + "The operator may attach photos or files (screenshots, documents, code) — look at them carefully and use what they contain. After acting, confirm what you did in one line with links. Reference projects/tasks by name. Keep replies tight unless asked to expand.\n\n" + "--- LIVE CONTEXT ---\n" + `Operator: ${user.firstName ?? "the founder"}.\n` + `Projects: ${projects.map((p) => `${p.name} (slug ${p.slug}) [${p.status} ${p.progress}%]`).join("; ") || "none"}\n` + + `GitHub repos you can build into (build_app_files targets): ${linkedRepos.join(", ") || "none linked yet — ask the operator for an owner/name repo"}\n` + `Top open tasks: ${tasks.map((t) => `[${t.priority}] ${t.title}`).join("; ") || "none"}`; + const instructions = parsed.data.mode === "builder" ? builderInstructions : opsInstructions; + const actions: { tool: string; label: string }[] = []; try { - // First turn: the full conversation as input. + // First turn: the full conversation as input. Messages with attachments + // become multimodal content (text + images + files). + const input = parsed.data.messages.map((m) => { + if (m.role !== "user" || !m.attachments?.length) { + return { role: m.role, content: m.content }; + } + const parts = attachmentParts(m.attachments); + return { + role: m.role, + content: [{ type: "input_text" as const, text: m.content }, ...parts], + }; + }); + let resp = await ai.responses.create({ model: OPENAI_MODEL, instructions, - input: parsed.data.messages.map((m) => ({ role: m.role, content: m.content })), + input, tools: COPILOT_TOOLS, store: true, }); diff --git a/src/app/api/github/commit/route.ts b/src/app/api/github/commit/route.ts new file mode 100644 index 0000000..14cc1c1 --- /dev/null +++ b/src/app/api/github/commit/route.ts @@ -0,0 +1,69 @@ +/** + * /api/github/commit + * POST → commit edited files to a branch from the in-browser code editor. + * Body: { repo, branch, message, files: [{path, content}] }. + * + * Operator-only, rate-limited. Uses the same guardrails and single-commit + * push plumbing as the Copilot's app builder. + */ + +import { z } from "zod"; +import { requireUser } from "@/lib/auth"; +import { audit } from "@/lib/audit"; +import { ok, apiErrors } from "@/lib/api-response"; +import { rateLimit } from "@/lib/rate-limit"; +import { pushFilesToRepo } from "@/lib/github"; +import { + isValidBranchName, + isValidRepoName, + validatePushFiles, + MAX_FILE_CHARS, + MAX_PUSH_FILES, +} from "@/lib/repo-files"; + +export const runtime = "nodejs"; + +const CommitSchema = z.object({ + repo: z.string().max(140), + branch: z.string().max(80), + message: z.string().min(1).max(200), + files: z + .array(z.object({ path: z.string().max(200), content: z.string().max(MAX_FILE_CHARS) })) + .min(1) + .max(MAX_PUSH_FILES), +}); + +export async function POST(req: Request) { + const user = await requireUser(); + + const rl = rateLimit(`code.commit:${user.id}`, { limit: 60, windowMs: 60 * 60 * 1000 }); + if (!rl.success) return apiErrors.rateLimit(rl.reset); + + let body: unknown; + try { + body = await req.json(); + } catch { + return apiErrors.badRequest("Request body must be valid JSON"); + } + const parsed = CommitSchema.safeParse(body); + if (!parsed.success) return apiErrors.validation(parsed.error); + + const { repo, branch, message, files } = parsed.data; + if (!isValidRepoName(repo)) return apiErrors.badRequest('repo must be "owner/name"'); + if (!isValidBranchName(branch)) return apiErrors.badRequest("invalid branch name"); + const check = validatePushFiles(files); + if (!check.ok) return apiErrors.badRequest(check.error); + + const result = await pushFilesToRepo(repo, { branch, message, files }); + if ("error" in result) return apiErrors.badRequest(result.error); + + await audit({ + action: "code.commit", + target: `${repo}#${branch}`, + actorId: user.id, + diff: { message, paths: files.map((f) => f.path) }, + req, + }); + + return ok(result); +} diff --git a/src/app/api/github/file/route.ts b/src/app/api/github/file/route.ts new file mode 100644 index 0000000..a2d6ca2 --- /dev/null +++ b/src/app/api/github/file/route.ts @@ -0,0 +1,34 @@ +/** + * /api/github/file?repo=owner/name&path=src/x.ts&ref=branch + * GET → one file's text content for the in-browser code editor. + * + * Operator-only, rate-limited. + */ + +import { requireUser } from "@/lib/auth"; +import { ok, apiErrors } from "@/lib/api-response"; +import { rateLimit } from "@/lib/rate-limit"; +import { fetchRepoFileContent } from "@/lib/github"; +import { isSafeRepoPath, isValidRepoName } from "@/lib/repo-files"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const user = await requireUser(); + + const rl = rateLimit(`code.read:${user.id}`, { limit: 600, windowMs: 60 * 60 * 1000 }); + if (!rl.success) return apiErrors.rateLimit(rl.reset); + + const url = new URL(req.url); + const repo = url.searchParams.get("repo") ?? ""; + const path = url.searchParams.get("path") ?? ""; + const ref = url.searchParams.get("ref") ?? undefined; + if (!isValidRepoName(repo)) return apiErrors.badRequest('repo must be "owner/name"'); + if (!isSafeRepoPath(path)) return apiErrors.badRequest("invalid file path"); + + const file = await fetchRepoFileContent(repo, path, ref); + if (!file) return apiErrors.badRequest("Couldn't read that file (missing, binary, or too large)."); + + return ok({ path, content: file.content }); +} diff --git a/src/app/api/github/repos/route.ts b/src/app/api/github/repos/route.ts new file mode 100644 index 0000000..2684ab9 --- /dev/null +++ b/src/app/api/github/repos/route.ts @@ -0,0 +1,24 @@ +/** + * /api/github/repos — GET: repos the GITHUB_TOKEN can access (the GCODE + * studio's repo picker and the editor's target list). Operator-only. + */ + +import { requireUser } from "@/lib/auth"; +import { ok, apiErrors } from "@/lib/api-response"; +import { rateLimit } from "@/lib/rate-limit"; +import { listAccessibleRepos } from "@/lib/github"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + const user = await requireUser(); + + const rl = rateLimit(`code.read:${user.id}`, { limit: 600, windowMs: 60 * 60 * 1000 }); + if (!rl.success) return apiErrors.rateLimit(rl.reset); + + const repos = await listAccessibleRepos(); + if (!repos) return apiErrors.badRequest("GitHub token missing or invalid."); + + return ok({ repos }); +} diff --git a/src/app/api/github/tree/route.ts b/src/app/api/github/tree/route.ts new file mode 100644 index 0000000..be61181 --- /dev/null +++ b/src/app/api/github/tree/route.ts @@ -0,0 +1,32 @@ +/** + * /api/github/tree?repo=owner/name&ref=branch + * GET → editable file list for the in-browser code editor. + * + * Operator-only, rate-limited. The repo must be reachable by GITHUB_TOKEN. + */ + +import { requireUser } from "@/lib/auth"; +import { ok, apiErrors } from "@/lib/api-response"; +import { rateLimit } from "@/lib/rate-limit"; +import { fetchRepoTree } from "@/lib/github"; +import { isValidRepoName } from "@/lib/repo-files"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const user = await requireUser(); + + const rl = rateLimit(`code.read:${user.id}`, { limit: 600, windowMs: 60 * 60 * 1000 }); + if (!rl.success) return apiErrors.rateLimit(rl.reset); + + const url = new URL(req.url); + const repo = url.searchParams.get("repo") ?? ""; + const ref = url.searchParams.get("ref") ?? undefined; + if (!isValidRepoName(repo)) return apiErrors.badRequest('repo must be "owner/name"'); + + const tree = await fetchRepoTree(repo, ref); + if (!tree) return apiErrors.badRequest(`Couldn't read ${repo} — check the token's repo access.`); + + return ok(tree); +} diff --git a/src/app/api/health/auth/route.ts b/src/app/api/health/auth/route.ts new file mode 100644 index 0000000..e60d4cf --- /dev/null +++ b/src/app/api/health/auth/route.ts @@ -0,0 +1,36 @@ +/** + * /api/health/auth — public, secret-free diagnostic. + * + * Reports WHETHER each piece of auth infrastructure is configured (never the + * values): Supabase URL/key presence, database reachability, and which commit + * is serving. Used to debug "auth doesn't work" reports against production. + */ + +import { prisma } from "@/lib/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? process.env.SUPABASE_URL; + const anonKey = + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? + process.env.SUPABASE_ANON_KEY; + + let database = false; + try { + await prisma.$queryRaw`SELECT 1`; + database = true; + } catch { + // leave false — that's the diagnostic signal + } + + return Response.json({ + supabaseUrlConfigured: Boolean(url), + supabaseAnonKeyConfigured: Boolean(anonKey), + databaseReachable: database, + commit: process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null, + deployedAt: process.env.VERCEL_DEPLOYMENT_ID ?? null, + }); +} diff --git a/src/app/api/projects/[id]/github/route.ts b/src/app/api/projects/[id]/github/route.ts new file mode 100644 index 0000000..cd69357 --- /dev/null +++ b/src/app/api/projects/[id]/github/route.ts @@ -0,0 +1,47 @@ +/** + * /api/projects/[id]/github + * GET → live activity for the project's linked GitHub repo (recent commits, + * open PRs, open issues). Fetched fresh from GitHub on every request — + * no caching — so the tracker reflects what's happening right now. + * + * Operator-only, owner-scoped, rate-limited. + */ + +import { requireUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { ok, apiErrors } from "@/lib/api-response"; +import { rateLimit } from "@/lib/rate-limit"; +import { fetchRepoActivity, hasGitHubToken } from "@/lib/github"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +interface Ctx { + params: Promise<{ id: string }>; +} + +export async function GET(_req: Request, ctx: Ctx) { + const user = await requireUser(); + const { id } = await ctx.params; + + const rl = rateLimit(`repo.activity:${user.id}`, { limit: 240, windowMs: 60 * 60 * 1000 }); + if (!rl.success) return apiErrors.rateLimit(rl.reset); + + const project = await prisma.project.findUnique({ + where: { id }, + select: { userId: true, sourceRepo: true }, + }); + if (!project || project.userId !== user.id) return apiErrors.notFound("Project"); + if (!project.sourceRepo) return apiErrors.badRequest("This project has no linked GitHub repo."); + + const activity = await fetchRepoActivity(project.sourceRepo); + if (!activity) { + return apiErrors.badRequest( + hasGitHubToken() + ? `Couldn't read ${project.sourceRepo} — check the token's repo access.` + : `Couldn't read ${project.sourceRepo} — set GITHUB_TOKEN for private repos and higher rate limits.`, + ); + } + + return ok({ activity, fetchedAt: new Date().toISOString() }); +} diff --git a/src/app/auth/actions.ts b/src/app/auth/actions.ts index b1b78f4..a88c1ac 100644 --- a/src/app/auth/actions.ts +++ b/src/app/auth/actions.ts @@ -2,16 +2,19 @@ import { redirect } from "next/navigation"; import { z } from "zod"; +import { isAuthorizedDashboardAuthUser } from "@/lib/auth"; import { - isAllowedOperatorEmail, + canEnrollWithInvite, normalizeEmail, safeRedirectPath, } from "@/lib/auth-policy"; +import { prisma } from "@/lib/prisma"; import { createSupabaseServerClient } from "@/lib/supabase/server"; const emailSchema = z.string().trim().email().max(320); const passwordSchema = z.string().min(8).max(128); const nameSchema = z.string().trim().max(80).optional(); +const inviteCodeSchema = z.string().trim().max(120).optional(); function stringValue(formData: FormData, key: string): string { const value = formData.get(key); @@ -43,20 +46,23 @@ export async function signInWithPasswordAction(formData: FormData) { const email = normalizeEmail(emailResult.data); - if (!isAllowedOperatorEmail(email)) { - redirectToAuthError("/sign-in", "not-allowed", next); - } - const supabase = await getSupabaseOrRedirect("/sign-in", next); - const { error } = await supabase.auth.signInWithPassword({ + const { data, error } = await supabase.auth.signInWithPassword({ email, password: passwordResult.data, }); - if (error) { + if (error || !data.user) { redirectToAuthError("/sign-in", "invalid-credentials", next); } + // Authorization happens after authentication: the owner email or an + // enrolled team member (existing local User row) may enter the dashboard. + if (!(await isAuthorizedDashboardAuthUser(data.user))) { + await supabase.auth.signOut(); + redirectToAuthError("/sign-in", "not-allowed", next); + } + redirect(next); } @@ -66,12 +72,14 @@ export async function signUpWithPasswordAction(formData: FormData) { const passwordResult = passwordSchema.safeParse(stringValue(formData, "password")); const firstNameResult = nameSchema.safeParse(stringValue(formData, "firstName")); const lastNameResult = nameSchema.safeParse(stringValue(formData, "lastName")); + const inviteCodeResult = inviteCodeSchema.safeParse(stringValue(formData, "inviteCode")); if ( !emailResult.success || !passwordResult.success || !firstNameResult.success || - !lastNameResult.success + !lastNameResult.success || + !inviteCodeResult.success ) { redirectToAuthError("/sign-up", "invalid-signup", next); } @@ -80,8 +88,8 @@ export async function signUpWithPasswordAction(formData: FormData) { const firstName = firstNameResult.data || undefined; const lastName = lastNameResult.data || undefined; - if (!isAllowedOperatorEmail(email)) { - redirectToAuthError("/sign-up", "not-allowed", next); + if (!canEnrollWithInvite(email, inviteCodeResult.data)) { + redirectToAuthError("/sign-up", "invalid-invite", next); } const supabase = await getSupabaseOrRedirect("/sign-up", next); @@ -97,7 +105,24 @@ export async function signUpWithPasswordAction(formData: FormData) { }, }); - if (error) { + if (error || !data.user) { + redirectToAuthError("/sign-up", "signup-failed", next); + } + + // Record membership now, while the invite code is in hand. Sign-in (and + // email confirmation later) authorizes against this local User row. + try { + await prisma.user.upsert({ + where: { email }, + update: {}, + create: { + clerkId: data.user.id, + email, + firstName: firstName ?? null, + lastName: lastName ?? null, + }, + }); + } catch { redirectToAuthError("/sign-up", "signup-failed", next); } diff --git a/src/app/dashboard/code/page.tsx b/src/app/dashboard/code/page.tsx new file mode 100644 index 0000000..7e92815 --- /dev/null +++ b/src/app/dashboard/code/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import { requireUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { CodeWorkspace } from "@/components/dashboard/code/code-workspace"; + +export const metadata: Metadata = { + title: "Code", + robots: { index: false, follow: false }, +}; + +/** + * In-browser code editor: open any repo the GITHUB_TOKEN can reach, edit + * files in Monaco, and commit to a branch — Replit-style, GitHub-backed. + */ +export default async function CodePage({ + searchParams, +}: { + searchParams: Promise<{ repo?: string | string[] }>; +}) { + const user = await requireUser(); + const params = await searchParams; + const requested = Array.isArray(params.repo) ? params.repo[0] : params.repo; + + const projects = await prisma.project.findMany({ + where: { userId: user.id, sourceRepo: { not: null } }, + select: { sourceRepo: true }, + orderBy: { updatedAt: "desc" }, + take: 20, + }); + const linkedRepos = Array.from( + new Set(projects.map((p) => p.sourceRepo).filter((r): r is string => Boolean(r))), + ); + + return ( +
+
+

Code

+

+ Edit your repos right here — changes commit straight to GitHub. +

+
+ +
+ ); +} diff --git a/src/app/dashboard/gcode/page.tsx b/src/app/dashboard/gcode/page.tsx new file mode 100644 index 0000000..5dbdd57 --- /dev/null +++ b/src/app/dashboard/gcode/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import { requireUser } from "@/lib/auth"; +import { GcodeStudio } from "@/components/dashboard/gcode/gcode-studio"; + +export const metadata: Metadata = { + title: "GCODE", + robots: { index: false, follow: false }, +}; + +/** + * GCODE — the app-builder studio. Describe an app; it gets built into a + * GitHub repo as a PR with a live Vercel preview. + */ +export default async function GcodePage() { + await requireUser(); + + return ( +
+
+

+ GCODE app builder +

+

+ Describe it. GCODE builds it, opens the PR, and gives you a live preview. +

+
+ +
+ ); +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index ad73902..c413c90 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { DashboardMobileNav } from "@/components/dashboard/dashboard-mobile-nav"; import { DashboardSidebar } from "@/components/dashboard/dashboard-sidebar"; import { DashboardTopbar } from "@/components/dashboard/dashboard-topbar"; import { getInboxUnreadCount } from "@/lib/dashboard/data"; @@ -45,6 +46,7 @@ export default async function DashboardLayout({
+
{children}
diff --git a/src/app/dashboard/projects/[slug]/page.tsx b/src/app/dashboard/projects/[slug]/page.tsx index e8330c6..c3999fd 100644 --- a/src/app/dashboard/projects/[slug]/page.tsx +++ b/src/app/dashboard/projects/[slug]/page.tsx @@ -6,6 +6,7 @@ import { getProjectDetail } from "@/lib/dashboard/data"; import { relativeTime } from "@/lib/dashboard/format"; import { cn } from "@/lib/utils"; import { CopilotSyncPanel } from "@/components/dashboard/copilot/copilot-sync-panel"; +import { RepoActivityPanel } from "@/components/dashboard/projects/repo-activity-panel"; export const metadata: Metadata = { title: "Project", @@ -69,6 +70,7 @@ export default async function ProjectDetailPage({ params }: { params: Promise<{ )} + {project.sourceRepo && ( )} diff --git a/src/app/sign-in/[[...sign-in]]/page.tsx b/src/app/sign-in/[[...sign-in]]/page.tsx index 7bd226f..3806662 100644 --- a/src/app/sign-in/[[...sign-in]]/page.tsx +++ b/src/app/sign-in/[[...sign-in]]/page.tsx @@ -11,7 +11,7 @@ type AuthSearchParams = Promise<{ const errorMessages: Record = { "invalid-credentials": "Email or password is incorrect.", - "not-allowed": "That email is not approved for the dashboard.", + "not-allowed": "That account is not approved for the dashboard.", "supabase-env-missing": "Supabase Auth is not configured yet.", }; @@ -47,14 +47,14 @@ export default async function SignInPage({ -

Operator sign-in · restricted access

+

Workspace sign-in · team access

Command Center

-

Sign in with your approved operator account.

+

Sign in with your Ghimtech workspace account.

{error && errorMessages[error] ? ( diff --git a/src/app/sign-up/[[...sign-up]]/page.tsx b/src/app/sign-up/[[...sign-up]]/page.tsx index 7482efa..b684611 100644 --- a/src/app/sign-up/[[...sign-up]]/page.tsx +++ b/src/app/sign-up/[[...sign-up]]/page.tsx @@ -10,7 +10,8 @@ type AuthSearchParams = Promise<{ const errorMessages: Record = { "invalid-signup": "Use a valid email and a password of at least 8 characters.", - "not-allowed": "That email is not approved for the dashboard.", + "invalid-invite": "A valid invite code is required to join the workspace.", + "not-allowed": "That account is not approved for the dashboard.", "signup-failed": "Enrollment failed. Try signing in if the account already exists.", "supabase-env-missing": "Supabase Auth is not configured yet.", }; @@ -42,14 +43,16 @@ export default async function SignUpPage({ -

Operator enrollment

+

Team enrollment

Create Account

-

Create the dashboard operator account.

+

+ Join the Ghimtech workspace with your team invite code. +

{error && errorMessages[error] ? ( @@ -111,6 +114,21 @@ export default async function SignUpPage({ placeholder="8 characters minimum" /> + +
+ + {branch && ( + + on {branch} + + )} + +
+ {commitNote && {commitNote}} + setMessage(e.target.value)} + placeholder={dirtyCount ? "Commit message" : "No changes yet"} + disabled={!dirtyCount} + className="w-56 bg-white/[0.03] border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white placeholder:text-zinc-600 focus:outline-none focus:border-signal-400/50 disabled:opacity-50" + /> + +
+ + +
+ {/* File tree */} + + + {/* Editor / preview */} +
+ {selected && ( +
+ + {selected} + {isHtml && ( + + )} +
+ )} +
+ {!selected ? ( +
+ Select a file to start editing. +
+ ) : loadingFile ? ( +
+ + loading file… + +
+ ) : preview && isHtml ? ( +