- Your copilot can browse the web and act on your data — create tasks, update status, read your repos.
+ Your copilot can browse the web, act on your data, and look at photos or files you attach.
Try one:
@@ -107,6 +195,19 @@ export function CopilotChat() {
)}
>
{m.content}
+ {m.attachments && m.attachments.length > 0 && (
+
+ {m.attachments.map((a, j) => (
+
+ {a.kind === "image" ? : }
+ {a.name}
+
+ ))}
+
+ )}
{labels.length > 0 && (
@@ -134,6 +235,29 @@ export function CopilotChat() {
)}
+ {(pending.length > 0 || attachError) && (
+
+ {pending.map((a, i) => (
+
+ {a.kind === "image" ? : }
+ {a.name}
+
+
+ ))}
+ {attachError && {attachError}}
+
+ )}
+
diff --git a/src/components/dashboard/dashboard-mobile-nav.tsx b/src/components/dashboard/dashboard-mobile-nav.tsx
new file mode 100644
index 0000000..8b2ac8c
--- /dev/null
+++ b/src/components/dashboard/dashboard-mobile-nav.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { NAV_ITEMS } from "@/components/dashboard/dashboard-sidebar";
+import { cn } from "@/lib/utils";
+
+/**
+ * Mobile dashboard navigation. The sidebar is desktop-only (hidden below
+ * `lg`), which previously left phones with NO dashboard navigation at all —
+ * this horizontally-scrollable rail under the topbar fills that gap.
+ */
+export function DashboardMobileNav({ inboxUnread = 0 }: { inboxUnread?: number }) {
+ const pathname = usePathname();
+
+ return (
+
+ );
+}
diff --git a/src/components/dashboard/dashboard-sidebar.tsx b/src/components/dashboard/dashboard-sidebar.tsx
index fff1c4d..902f19e 100644
--- a/src/components/dashboard/dashboard-sidebar.tsx
+++ b/src/components/dashboard/dashboard-sidebar.tsx
@@ -13,6 +13,7 @@ import {
Settings,
ArrowLeft,
Sparkles,
+ Rocket,
} from "lucide-react";
import { Logo } from "@/components/shared/logo";
import { cn } from "@/lib/utils";
@@ -29,7 +30,7 @@ import { cn } from "@/lib/utils";
* the dashboard is an operator surface, not a marketing page.
*/
-const NAV_ITEMS = [
+export const NAV_ITEMS = [
{ href: "/dashboard", label: "Overview", icon: LayoutDashboard },
{ href: "/dashboard/inbox", label: "Inbox", icon: Inbox },
{ href: "/dashboard/projects", label: "Projects", icon: FolderKanban },
@@ -37,6 +38,7 @@ const NAV_ITEMS = [
{ href: "/dashboard/analytics", label: "Analytics", icon: BarChart3 },
{ href: "/dashboard/notifications", label: "Alerts", icon: Bell },
{ href: "/dashboard/copilot", label: "AI Copilot", icon: Sparkles },
+ { href: "/dashboard/gcode", label: "GCODE", icon: Rocket },
] as const;
export function DashboardSidebar({ inboxUnread = 0 }: { inboxUnread?: number }) {
diff --git a/src/components/dashboard/gcode/gcode-studio.tsx b/src/components/dashboard/gcode/gcode-studio.tsx
new file mode 100644
index 0000000..d74001a
--- /dev/null
+++ b/src/components/dashboard/gcode/gcode-studio.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { Send, Loader2, Rocket, Wrench, FolderGit2, ExternalLink } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface Action {
+ tool: string;
+ label: string;
+}
+interface Msg {
+ role: "user" | "assistant";
+ content: string;
+ actions?: Action[];
+}
+
+const STARTERS = [
+ "A waitlist landing page with a hero and email signup",
+ "A personal portfolio site with project cards",
+ "A pricing page with three tiers",
+ "A simple blog with three sample posts",
+];
+
+/** Render assistant text with clickable links — the PR/preview URL IS the product. */
+function Linkified({ text }: { text: string }) {
+ const parts = text.split(/(https?:\/\/[^\s)]+)/g);
+ return (
+ <>
+ {parts.map((part, i) =>
+ /^https?:\/\//.test(part) ? (
+
+ {part.replace(/^https:\/\/(www\.)?/, "").slice(0, 60)}
+
+
+ ) : (
+
{part}
+ ),
+ )}
+ >
+ );
+}
+
+/**
+ * GCODE — the app-builder studio. Pick a repo, describe the app; GCODE writes
+ * the code, opens the PR, and the PR grows a live preview URL. A focused
+ * builder-mode persona of the copilot chat API.
+ */
+export function GcodeStudio() {
+ const [messages, setMessages] = useState
([]);
+ const [input, setInput] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [repos, setRepos] = useState([]);
+ const [repo, setRepo] = useState("");
+ const [repoError, setRepoError] = useState(null);
+ const scrollRef = useRef(null);
+
+ useEffect(() => {
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
+ }, [messages, busy]);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const res = await fetch("/api/github/repos", { cache: "no-store" });
+ const json = await res.json().catch(() => null);
+ if (cancelled) return;
+ if (res.ok && json?.ok) {
+ const names = (json.data.repos as { repo: string }[]).map((r) => r.repo);
+ setRepos(names);
+ if (names.length && !repo) setRepo(names[0]);
+ } else {
+ setRepoError(json?.error?.message ?? "Couldn't list your repos.");
+ }
+ } catch {
+ if (!cancelled) setRepoError("Couldn't list your repos.");
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ async function send(text: string) {
+ const content = text.trim();
+ if (!content || busy) return;
+ const history: Msg[] = [...messages, { role: "user", content }];
+ setMessages(history);
+ setInput("");
+ setBusy(true);
+ try {
+ const res = await fetch("/api/copilot/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: history.map((m) => ({ role: m.role, content: m.content })),
+ mode: "builder",
+ ...(repo ? { repo } : {}),
+ }),
+ });
+ const json = await res.json().catch(() => null);
+ if (!res.ok || !json?.ok) {
+ setMessages((m) => [...m, { role: "assistant", content: json?.error?.message ?? "Something went wrong." }]);
+ } else {
+ setMessages((m) => [...m, { role: "assistant", content: json.data.text, actions: json.data.actions }]);
+ }
+ } catch {
+ setMessages((m) => [...m, { role: "assistant", content: "Network error. Try again." }]);
+ }
+ setBusy(false);
+ }
+
+ const uniqueLabels = (actions?: Action[]) =>
+ actions?.length ? Array.from(new Set(actions.map((a) => a.label))) : [];
+
+ return (
+
+ {/* Build target */}
+
+
+ Build into
+
+ {repoError && {repoError}}
+
+
+
+ {messages.length === 0 && !busy ? (
+
+
+
+
+ Welcome to GCODE
+
+
+ Describe the app. GCODE writes the code, opens the pull request, and hands you a live
+ preview — built straight into your GitHub repo.
+
+
Try one:
+
+ {STARTERS.map((sx) => (
+
+ ))}
+
+
+
+ ) : (
+ <>
+ {messages.map((m, i) => {
+ const labels = uniqueLabels(m.actions);
+ return (
+
+
+
+ {m.role === "assistant" ? : m.content}
+
+ {labels.length > 0 && (
+
+
+ {labels.map((l, j) => (
+
+ {l}
+ {j < labels.length - 1 ? " ·" : ""}
+
+ ))}
+
+ )}
+
+
+ );
+ })}
+ {busy && (
+
+
+ GCODE is building — writing files, pushing,
+ opening the PR…
+
+
+ )}
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/dashboard/projects/repo-activity-panel.tsx b/src/components/dashboard/projects/repo-activity-panel.tsx
new file mode 100644
index 0000000..3b8ae3d
--- /dev/null
+++ b/src/components/dashboard/projects/repo-activity-panel.tsx
@@ -0,0 +1,340 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { GitBranch, GitCommitHorizontal, GitPullRequest, CircleDot, Code2, Loader2, Star, Pencil, Rocket } from "lucide-react";
+import { relativeTime } from "@/lib/dashboard/format";
+
+interface RepoActivity {
+ repo: string;
+ url: string;
+ defaultBranch: string;
+ pushedAt: string | null;
+ stars: number;
+ commits: { sha: string; message: string; author: string | null; date: string | null; url: string }[];
+ pullRequests: { number: number; title: string; author: string | null; draft: boolean; updatedAt: string; url: string }[];
+ issues: { number: number; title: string; updatedAt: string; url: string }[];
+ deployments: { environment: string; state: string; url: string | null; createdAt: string }[];
+}
+
+const DEPLOY_STATE_CLS: Record = {
+ success: "text-vital-300",
+ failure: "text-flare-200",
+ error: "text-flare-200",
+ pending: "text-zinc-400",
+ in_progress: "text-zinc-400",
+};
+
+const REFRESH_MS = 60_000;
+
+function cnDeploy(state: string): string {
+ return `font-mono text-[10px] shrink-0 ${DEPLOY_STATE_CLS[state] ?? "text-zinc-400"}`;
+}
+
+/**
+ * Live GitHub activity for a project. Polls the API (which reads GitHub
+ * uncached) every minute while the tab is visible, so the tracker shows
+ * what's actually happening in the repo right now. When the project has no
+ * linked repo yet, renders an inline "link a repo" form instead (PATCHes
+ * the project's sourceRepo).
+ */
+export function RepoActivityPanel({ projectId, repo: initialRepo }: { projectId: string; repo: string | null }) {
+ const router = useRouter();
+ const [repo, setRepo] = useState(initialRepo);
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(initialRepo ?? "");
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+ const [activity, setActivity] = useState(null);
+ const [error, setError] = useState(null);
+ const [fetchedAt, setFetchedAt] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ try {
+ const res = await fetch(`/api/projects/${projectId}/github`, { cache: "no-store" });
+ const json = await res.json().catch(() => null);
+ if (!res.ok || !json?.ok) {
+ setError(json?.error?.message ?? "Couldn't reach GitHub.");
+ } else {
+ setActivity(json.data.activity);
+ setFetchedAt(json.data.fetchedAt);
+ setError(null);
+ }
+ } catch {
+ setError("Couldn't reach GitHub.");
+ }
+ setLoading(false);
+ }, [projectId]);
+
+ useEffect(() => {
+ if (!repo) return;
+ setLoading(true);
+ void load();
+ const timer = setInterval(() => {
+ if (document.visibilityState === "visible") void load();
+ }, REFRESH_MS);
+ return () => clearInterval(timer);
+ }, [load, repo]);
+
+ async function saveRepo() {
+ const value = draft.trim();
+ if (!/^[\w.-]+\/[\w.-]+$/.test(value)) {
+ setSaveError('Use "owner/name", e.g. durga710/rayhealth-evv-platform');
+ return;
+ }
+ setSaving(true);
+ setSaveError(null);
+ try {
+ const res = await fetch(`/api/projects/${projectId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sourceRepo: value }),
+ });
+ const json = await res.json().catch(() => null);
+ if (!res.ok || !json?.ok) {
+ setSaveError(json?.error?.message ?? "Couldn't save.");
+ } else {
+ setRepo(value);
+ setEditing(false);
+ setActivity(null);
+ setError(null);
+ router.refresh();
+ }
+ } catch {
+ setSaveError("Couldn't save.");
+ }
+ setSaving(false);
+ }
+
+ const linkForm = (
+
+ );
+
+ if (!repo) {
+ return (
+
+
+
+
Live repo activity
+
+
+ Link this project to a GitHub repo to track commits, pull requests, and issues live.
+
+ {linkForm}
+
+ );
+ }
+
+ return (
+
+
+
+
Live repo activity
+
+ {repo}
+
+
+
+
edit code
+
+
+ {loading ? (
+
+ ) : (
+
+ )}
+ {fetchedAt ? `updated ${relativeTime(fetchedAt)}` : "connecting…"}
+
+
+
+ {editing && {linkForm}
}
+
+ {error ? (
+ {error}
+ ) : !activity ? (
+ Reading {repo}…
+ ) : (
+ <>
+
+
+ last push {activity.pushedAt ? relativeTime(activity.pushedAt) : "—"} · {activity.defaultBranch}
+
+
+ {activity.pullRequests.length} open PR
+ {activity.pullRequests.length === 1 ? "" : "s"}
+
+
+ {activity.issues.length} open issue
+ {activity.issues.length === 1 ? "" : "s"}
+
+
+ {activity.stars}
+
+
+
+
+
+
Recent commits
+ {activity.commits.length === 0 ? (
+
No commits yet.
+ ) : (
+
+ {activity.commits.slice(0, 6).map((c) => (
+ -
+
+
+ {c.message}
+
+
+ {c.date ? relativeTime(c.date) : c.sha}
+
+
+ ))}
+
+ )}
+
+
+
+
+
Open pull requests
+ {activity.pullRequests.length === 0 ? (
+
None open.
+ ) : (
+
+ )}
+
+
+ {activity.deployments.length > 0 && (
+
+
Deployments
+
+ {activity.deployments.slice(0, 3).map((d, i) => (
+ -
+
+ {d.environment}
+ {d.state}
+ {d.url && (
+
+ open
+
+ )}
+
+ {relativeTime(d.createdAt)}
+
+
+ ))}
+
+
+ )}
+
+
+
Open issues
+ {activity.issues.length === 0 ? (
+
None open.
+ ) : (
+
+ {activity.issues.slice(0, 4).map((i) => (
+ -
+ #{i.number}
+
+ {i.title}
+
+
+ {relativeTime(i.updatedAt)}
+
+
+ ))}
+
+ )}
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/lib/auth-policy.ts b/src/lib/auth-policy.ts
index b5b569b..6deb96b 100644
--- a/src/lib/auth-policy.ts
+++ b/src/lib/auth-policy.ts
@@ -13,6 +13,33 @@ export function isAllowedOperatorEmail(email: string | null | undefined): boolea
return Boolean(allowedEmail) && normalizeEmail(email) === allowedEmail;
}
+/**
+ * Invite codes let teammates enroll without being the workspace owner.
+ * Configure via AUTH_TEAM_INVITE_CODES as a comma-separated list.
+ */
+export function getTeamInviteCodes(): string[] {
+ return (process.env.AUTH_TEAM_INVITE_CODES ?? "")
+ .split(",")
+ .map((code) => code.trim())
+ .filter(Boolean);
+}
+
+export function isValidTeamInviteCode(code: string | null | undefined): boolean {
+ const candidate = (code ?? "").trim();
+ return Boolean(candidate) && getTeamInviteCodes().includes(candidate);
+}
+
+/**
+ * Sign-up gate: the workspace owner enrolls with their email alone;
+ * everyone else needs a valid invite code.
+ */
+export function canEnrollWithInvite(
+ email: string | null | undefined,
+ inviteCode: string | null | undefined
+): boolean {
+ return isAllowedOperatorEmail(email) || isValidTeamInviteCode(inviteCode);
+}
+
export function safeRedirectPath(value: FormDataEntryValue | string | null | undefined): string {
if (typeof value !== "string") return "/dashboard";
diff --git a/src/lib/auth-route-policy.ts b/src/lib/auth-route-policy.ts
new file mode 100644
index 0000000..2d67280
--- /dev/null
+++ b/src/lib/auth-route-policy.ts
@@ -0,0 +1,37 @@
+/**
+ * Edge-safe routing rules shared by the middleware and auth pages.
+ * No server-only or database imports here — this runs in the edge runtime.
+ */
+
+export const PROTECTED_ROUTE_PREFIXES = ["/dashboard", "/admin", "/api/private"] as const;
+export const AUTH_ROUTE_PREFIXES = ["/sign-in", "/sign-up"] as const;
+
+export function matchesRoutePrefix(
+ pathname: string,
+ prefixes: readonly string[]
+): boolean {
+ return prefixes.some(
+ (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
+ );
+}
+
+export function isProtectedRoute(pathname: string): boolean {
+ return matchesRoutePrefix(pathname, PROTECTED_ROUTE_PREFIXES);
+}
+
+export function isAuthRoute(pathname: string): boolean {
+ return matchesRoutePrefix(pathname, AUTH_ROUTE_PREFIXES);
+}
+
+/**
+ * Authenticated visitors on /sign-in or /sign-up normally bounce to the
+ * dashboard — except when the page is showing an auth error (for example
+ * ?error=not-allowed). Those must stay reachable, otherwise an authenticated
+ * but unauthorized session loops between the dashboard guard and sign-in.
+ */
+export function shouldRedirectAuthenticatedToDashboard(
+ pathname: string,
+ searchParams: URLSearchParams
+): boolean {
+ return isAuthRoute(pathname) && !searchParams.has("error");
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 1d07a5f..6f28a6b 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -128,6 +128,27 @@ async function ensureLocalUser(authUser: SupabaseUser): Promise {
});
}
+/**
+ * Dashboard authorization: the workspace owner is always allowed; anyone
+ * else must already have a local User row (created during invite-code
+ * enrollment). Supabase Auth alone is authentication, not membership.
+ */
+export async function isAuthorizedDashboardAuthUser(
+ authUser: SupabaseUser
+): Promise {
+ if (isAllowedOperatorEmail(authUser.email)) return true;
+
+ const email = normalizeEmail(authUser.email);
+ const member = await prisma.user.findFirst({
+ where: {
+ OR: [{ clerkId: authUser.id }, ...(email ? [{ email }] : [])],
+ },
+ select: { id: true },
+ });
+
+ return member !== null;
+}
+
async function getAuthenticatedSupabaseUser(): Promise {
let supabase;
@@ -147,7 +168,7 @@ async function getAuthenticatedSupabaseUser(): Promise {
if (error || !user) return null;
- if (!isAllowedOperatorEmail(user.email)) {
+ if (!(await isAuthorizedDashboardAuthUser(user))) {
await supabase.auth.signOut();
redirect("/sign-in?error=not-allowed");
}
@@ -199,10 +220,12 @@ export async function getOptionalUser(): Promise {
error,
} = await supabase.auth.getUser();
- if (error || !authUser || !isAllowedOperatorEmail(authUser.email)) return null;
+ if (error || !authUser) return null;
const email = normalizeEmail(authUser.email);
+ // The local User row doubles as the membership record: anyone without one
+ // (and who isn't the owner, provisioned lazily by requireUser) gets null.
return prisma.user.findFirst({
where: {
OR: [{ clerkId: authUser.id }, { email }],
diff --git a/src/lib/copilot-tools.ts b/src/lib/copilot-tools.ts
index 1a12ac7..3cab670 100644
--- a/src/lib/copilot-tools.ts
+++ b/src/lib/copilot-tools.ts
@@ -1,6 +1,20 @@
import "server-only";
import { prisma } from "@/lib/prisma";
-import { fetchRepoContext, scanRepoSecrets, openPullRequest } from "@/lib/github";
+import {
+ createPullRequest,
+ fetchRepoActivity,
+ fetchRepoContext,
+ listAccessibleRepos,
+ openPullRequest,
+ pushFilesToRepo,
+ scanRepoSecrets,
+} from "@/lib/github";
+import {
+ isValidBranchName,
+ isValidRepoName,
+ validatePushFiles,
+ MAX_PUSH_FILES,
+} from "@/lib/repo-files";
/**
* Copilot agent tools. `web_search` is OpenAI's built-in browsing tool
@@ -79,6 +93,19 @@ export const COPILOT_TOOLS = [
},
strict: false,
},
+ {
+ type: "function" as const,
+ name: "read_repo_activity",
+ description:
+ "Live GitHub activity for a project's linked repo by project slug: recent commits, open pull requests, open issues, last push time. Use for 'what's happening / what changed recently' questions.",
+ parameters: {
+ type: "object",
+ properties: { slug: { type: "string" } },
+ required: ["slug"],
+ additionalProperties: false,
+ },
+ strict: false,
+ },
{
type: "function" as const,
name: "scan_repo_secrets",
@@ -91,6 +118,44 @@ export const COPILOT_TOOLS = [
},
strict: false,
},
+ {
+ type: "function" as const,
+ name: "list_github_repos",
+ description:
+ "List ALL GitHub repos the token can access (name, private, default branch, last push). Call this BEFORE build_app_files when unsure of the target, and whenever a repo seems unreachable - it is the source of truth for valid push targets.",
+ parameters: { type: "object", properties: {}, additionalProperties: false },
+ strict: false,
+ },
+ {
+ type: "function" as const,
+ name: "build_app_files",
+ description:
+ "BUILD AN APP: write a complete set of files (up to " +
+ MAX_PUSH_FILES +
+ " per call) to a branch of one of the operator's GitHub repos in a single commit, and optionally open a PR. Repos connected to Vercel automatically get a live preview deployment on the PR — so describing an app, building its files here, and opening a PR yields a running preview URL. Call again with the same branch to add or fix files.",
+ parameters: {
+ type: "object",
+ properties: {
+ repo: { type: "string", description: 'target repo as "owner/name"' },
+ branch: { type: "string", description: "branch to write to; omit to create a new copilot/app-* branch" },
+ message: { type: "string", description: "commit message" },
+ files: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: { path: { type: "string" }, content: { type: "string" } },
+ required: ["path", "content"],
+ additionalProperties: false,
+ },
+ },
+ prTitle: { type: "string", description: "open a PR with this title (omit to just push the branch)" },
+ prBody: { type: "string" },
+ },
+ required: ["repo", "message", "files"],
+ additionalProperties: false,
+ },
+ strict: false,
+ },
{
type: "function" as const,
name: "open_pull_request",
@@ -194,6 +259,14 @@ export async function executeTool(
recentCommits: ctx.recentCommits,
};
}
+ case "read_repo_activity": {
+ const slug = s(args.slug);
+ const p = await prisma.project.findFirst({ where: { slug, userId }, select: { sourceRepo: true } });
+ if (!p?.sourceRepo) return { error: "that project has no linked repo" };
+ const activity = await fetchRepoActivity(p.sourceRepo);
+ if (!activity) return { error: "couldn't read the repo" };
+ return activity;
+ }
case "scan_repo_secrets": {
const slug = s(args.slug);
const p = await prisma.project.findFirst({ where: { slug, userId }, select: { sourceRepo: true } });
@@ -202,6 +275,46 @@ export async function executeTool(
if (!res) return { error: "couldn't read the repo" };
return { repo: p.sourceRepo, scannedFiles: res.scannedFiles, findingCount: res.findings.length, findings: res.findings.slice(0, 40) };
}
+ case "list_github_repos": {
+ const repos = await listAccessibleRepos();
+ if (!repos) return { error: "GitHub token missing or invalid" };
+ return { count: repos.length, repos: repos.slice(0, 60) };
+ }
+ case "build_app_files": {
+ const repo = s(args.repo);
+ if (!isValidRepoName(repo)) return { error: 'repo must be "owner/name"' };
+
+ const rawFiles = Array.isArray(args.files) ? args.files : [];
+ const files = rawFiles.map((f) => {
+ const o = f && typeof f === "object" ? (f as Record) : {};
+ return { path: s(o.path), content: typeof o.content === "string" ? o.content : "" };
+ });
+ const check = validatePushFiles(files);
+ if (!check.ok) return { error: check.error };
+
+ const branch = s(args.branch) || `copilot/app-${Date.now().toString(36)}`;
+ if (!isValidBranchName(branch)) return { error: "invalid branch name" };
+ const message = s(args.message).slice(0, 200) || "Copilot: build app files";
+
+ const pushed = await pushFilesToRepo(repo, { branch, message, files });
+ if ("error" in pushed) return pushed;
+
+ const prTitle = s(args.prTitle).slice(0, 200);
+ if (!prTitle) return { repo, branch, commitUrl: pushed.commitUrl, fileCount: files.length };
+
+ const pr = await createPullRequest(repo, {
+ title: prTitle,
+ body: s(args.prBody).slice(0, 4000) || prTitle,
+ head: branch,
+ });
+ return {
+ repo,
+ branch,
+ commitUrl: pushed.commitUrl,
+ fileCount: files.length,
+ ...("url" in pr ? { prUrl: pr.url } : { prError: pr.error }),
+ };
+ }
case "open_pull_request": {
const slug = s(args.slug);
const p = await prisma.project.findFirst({ where: { slug, userId }, select: { sourceRepo: true } });
@@ -242,8 +355,18 @@ export function toolLabel(name: string, result: unknown): string {
return r.created ? "saved a note" : "tried to save a note";
case "read_project_repo":
return r.repo ? `read ${String(r.repo)}` : "tried to read a repo";
+ case "read_repo_activity":
+ return r.repo ? `checked live activity on ${String(r.repo)}` : "tried to check repo activity";
case "scan_repo_secrets":
return r.findingCount !== undefined ? `scanned ${String(r.repo ?? "repo")} — ${String(r.findingCount)} finding(s)` : "scanned a repo";
+ case "list_github_repos":
+ return r.count !== undefined ? `listed ${String(r.count)} accessible repo(s)` : "tried to list repos";
+ case "build_app_files":
+ return r.prUrl
+ ? `built ${String(r.fileCount ?? "")} file(s) & opened a PR`
+ : r.branch
+ ? `pushed ${String(r.fileCount ?? "")} file(s) to ${String(r.branch)}`
+ : "tried to build app files";
case "open_pull_request":
return r.url ? "opened a pull request" : "tried to open a PR";
default:
diff --git a/src/lib/github.ts b/src/lib/github.ts
index 2c7eeb9..a038c96 100644
--- a/src/lib/github.ts
+++ b/src/lib/github.ts
@@ -94,6 +94,110 @@ export async function fetchRepoContext(repo: string): Promise {
+ const meta = await ghJson<{
+ full_name?: string;
+ html_url?: string;
+ default_branch?: string;
+ pushed_at?: string;
+ stargazers_count?: number;
+ }>(`/repos/${repo}`);
+ if (!meta?.full_name) return null;
+
+ const [commitsRaw, pullsRaw, issuesRaw, deploymentsRaw] = await Promise.all([
+ ghJson>(`/repos/${repo}/commits?per_page=10`),
+ ghJson>(`/repos/${repo}/pulls?state=open&per_page=10`),
+ ghJson>(`/repos/${repo}/issues?state=open&per_page=15`),
+ ghJson>(
+ `/repos/${repo}/deployments?per_page=4`,
+ ),
+ ]);
+
+ // Vercel (and other platforms) report deploys via the GitHub deployments
+ // API, so one read-only token covers code AND deploy status.
+ const deployments = await Promise.all(
+ (deploymentsRaw ?? []).map(async (d) => {
+ const statuses = await ghJson>(
+ `/repos/${repo}/deployments/${d.id}/statuses?per_page=1`,
+ );
+ const latest = statuses?.[0];
+ return {
+ environment: d.environment ?? "unknown",
+ state: latest?.state ?? "pending",
+ url: latest?.environment_url ?? latest?.target_url ?? null,
+ createdAt: d.created_at,
+ };
+ }),
+ );
+
+ return {
+ repo: meta.full_name,
+ url: meta.html_url ?? `https://github.com/${repo}`,
+ defaultBranch: meta.default_branch ?? "main",
+ pushedAt: meta.pushed_at ?? null,
+ stars: meta.stargazers_count ?? 0,
+ commits: (commitsRaw ?? []).map((c) => ({
+ sha: c.sha.slice(0, 7),
+ message: c.commit.message.split("\n")[0].slice(0, 160),
+ author: c.author?.login ?? c.commit.author?.name ?? null,
+ date: c.commit.author?.date ?? null,
+ url: c.html_url,
+ })),
+ pullRequests: (pullsRaw ?? []).map((p) => ({
+ number: p.number,
+ title: p.title.slice(0, 160),
+ author: p.user?.login ?? null,
+ draft: Boolean(p.draft),
+ updatedAt: p.updated_at,
+ url: p.html_url,
+ })),
+ issues: (issuesRaw ?? [])
+ .filter((i) => !i.pull_request)
+ .map((i) => ({
+ number: i.number,
+ title: i.title.slice(0, 160),
+ updatedAt: i.updated_at,
+ url: i.html_url,
+ })),
+ deployments,
+ };
+}
+
/* ============================================================
Write + scan helpers (used by the agentic Copilot tools)
============================================================ */
@@ -185,6 +289,153 @@ export async function scanRepoSecrets(
return { scannedFiles: scanned, findings: uniq };
}
+export interface RepoTreeEntry {
+ path: string;
+ size: number;
+}
+
+/**
+ * Lists the repo's text-editable files (blobs) on a branch for the in-browser
+ * editor. Skips vendored/build dirs; capped so huge monorepos stay usable.
+ */
+export async function fetchRepoTree(
+ repo: string,
+ ref?: string,
+): Promise<{ branch: string; files: RepoTreeEntry[] } | null> {
+ const meta = await ghJson<{ default_branch?: string }>(`/repos/${repo}`);
+ if (!meta?.default_branch) return null;
+ const branch = ref || meta.default_branch;
+
+ const tree = await ghJson<{ tree?: { path: string; type: string; size?: number }[] }>(
+ `/repos/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`,
+ );
+ if (!tree?.tree) return null;
+
+ const SKIP = /(^|\/)(node_modules|dist|build|\.next|vendor|\.git)(\/|$)/;
+ const files = tree.tree
+ .filter((n) => n.type === "blob" && !SKIP.test(n.path))
+ .slice(0, 500)
+ .map((n) => ({ path: n.path, size: n.size ?? 0 }));
+
+ return { branch, files };
+}
+
+/** Reads one file's text content from a branch. Null if missing/too big. */
+export async function fetchRepoFileContent(
+ repo: string,
+ path: string,
+ ref?: string,
+): Promise<{ content: string } | null> {
+ const refQuery = ref ? `?ref=${encodeURIComponent(ref)}` : "";
+ const data = await ghJson<{ content?: string; encoding?: string; size?: number }>(
+ `/repos/${repo}/contents/${path}${refQuery}`,
+ );
+ if (!data || data.size === undefined || data.size > 400_000) return null;
+ const content = decodeBase64(data.content);
+ if (content === null || content.includes("\u0000")) return null;
+ return { content };
+}
+
+/**
+ * Lists the repos the GITHUB_TOKEN can actually reach. For fine-grained PATs
+ * this is exactly the granted repo list — the source of truth for where the
+ * Copilot can build. Null if the token is missing/invalid.
+ */
+export async function listAccessibleRepos(): Promise<
+ { repo: string; private: boolean; defaultBranch: string; pushedAt: string | null }[] | null
+> {
+ const repos = await ghJson<
+ Array<{ full_name: string; private: boolean; default_branch: string; pushed_at?: string }>
+ >(`/user/repos?per_page=100&sort=pushed`);
+ if (!repos) return null;
+ return repos.map((r) => ({
+ repo: r.full_name,
+ private: r.private,
+ defaultBranch: r.default_branch,
+ pushedAt: r.pushed_at ?? null,
+ }));
+}
+
+/**
+ * Pushes a set of files to a branch in one commit using the git data API
+ * (blob/tree/commit/ref). Creates the branch off the default branch if it
+ * doesn't exist; otherwise commits on top of it. This is how the Copilot
+ * builds multi-file apps into a repo.
+ */
+export async function pushFilesToRepo(
+ repo: string,
+ opts: { branch: string; message: string; files: { path: string; content: string }[] },
+): Promise<{ branch: string; commitSha: string; commitUrl: string } | { error: string }> {
+ const meta = await ghJson<{ default_branch?: string }>(`/repos/${repo}`);
+ if (!meta?.default_branch)
+ return {
+ error:
+ `the GitHub token has no access to ${repo} (or it doesn't exist). ` +
+ `Fix: on GitHub → Settings → Developer settings → Fine-grained tokens → this token → Repository access, grant it ${repo} (or All repositories). ` +
+ `Use list_github_repos to see which repos ARE accessible right now.`,
+ };
+
+ const baseRef = await ghJson<{ object?: { sha: string } }>(
+ `/repos/${repo}/git/ref/heads/${meta.default_branch}`,
+ );
+ if (!baseRef?.object?.sha) return { error: "couldn't read the default branch" };
+
+ let headSha = baseRef.object.sha;
+ const created = await ghReq("POST", `/repos/${repo}/git/refs`, {
+ ref: `refs/heads/${opts.branch}`,
+ sha: headSha,
+ });
+ if (!created.ok) {
+ const existing = await ghJson<{ object?: { sha: string } }>(
+ `/repos/${repo}/git/ref/heads/${opts.branch}`,
+ );
+ if (!existing?.object?.sha) return { error: "couldn't create the branch (token may be read-only)" };
+ headSha = existing.object.sha;
+ }
+
+ const headCommit = await ghJson<{ tree?: { sha: string } }>(`/repos/${repo}/git/commits/${headSha}`);
+ if (!headCommit?.tree?.sha) return { error: "couldn't read the base commit" };
+
+ const treeRes = await ghReq("POST", `/repos/${repo}/git/trees`, {
+ base_tree: headCommit.tree.sha,
+ tree: opts.files.map((f) => ({ path: f.path, mode: "100644", type: "blob", content: f.content })),
+ });
+ const tree = treeRes.json as { sha?: string } | null;
+ if (!treeRes.ok || !tree?.sha) return { error: "couldn't write the files (token may be read-only)" };
+
+ const commitRes = await ghReq("POST", `/repos/${repo}/git/commits`, {
+ message: opts.message,
+ tree: tree.sha,
+ parents: [headSha],
+ });
+ const commit = commitRes.json as { sha?: string; html_url?: string } | null;
+ if (!commitRes.ok || !commit?.sha) return { error: "couldn't create the commit" };
+
+ const refRes = await ghReq("PATCH", `/repos/${repo}/git/refs/heads/${opts.branch}`, { sha: commit.sha });
+ if (!refRes.ok) return { error: "couldn't update the branch" };
+
+ return { branch: opts.branch, commitSha: commit.sha, commitUrl: commit.html_url ?? "" };
+}
+
+/** Opens a PR for an existing branch against the default branch. */
+export async function createPullRequest(
+ repo: string,
+ opts: { title: string; body: string; head: string },
+): Promise<{ url: string } | { error: string }> {
+ const meta = await ghJson<{ default_branch?: string }>(`/repos/${repo}`);
+ if (!meta?.default_branch) return { error: "repo not found or no access" };
+
+ const prRes = await ghReq("POST", `/repos/${repo}/pulls`, {
+ title: opts.title,
+ body: opts.body,
+ head: opts.head,
+ base: meta.default_branch,
+ });
+ const prJson = prRes.json as { html_url?: string } | null;
+ if (!prRes.ok || !prJson?.html_url) return { error: "couldn't open the PR" };
+ return { url: prJson.html_url };
+}
+
/**
* Opens a single-file pull request on a repo: creates a branch off the default
* branch, commits the file, and opens a PR. Reviewable — never auto-merged.
@@ -213,13 +464,7 @@ export async function openPullRequest(
});
if (!fileRes.ok) return { error: "couldn't commit the file" };
- const prRes = await ghReq("POST", `/repos/${repo}/pulls`, {
- title: opts.title,
- body: opts.body,
- head: opts.branch,
- base,
- });
- const prJson = prRes.json as { html_url?: string } | null;
- if (!prRes.ok || !prJson?.html_url) return { error: "branch + file created, but couldn't open the PR" };
- return { url: prJson.html_url };
+ const pr = await createPullRequest(repo, { title: opts.title, body: opts.body, head: opts.branch });
+ if ("error" in pr) return { error: "branch + file created, but couldn't open the PR" };
+ return pr;
}
diff --git a/src/lib/repo-files.ts b/src/lib/repo-files.ts
new file mode 100644
index 0000000..6b0a290
--- /dev/null
+++ b/src/lib/repo-files.ts
@@ -0,0 +1,64 @@
+/**
+ * Validation for Copilot-pushed repo files. Pure module (no server deps) so
+ * the rules are unit-testable: the agent builds apps by writing files into
+ * the operator's repos, and these are the guardrails on what it may write.
+ */
+
+export const MAX_PUSH_FILES = 15;
+export const MAX_FILE_CHARS = 48_000;
+export const MAX_TOTAL_CHARS = 256_000;
+
+export interface PushFile {
+ path: string;
+ content: string;
+}
+
+export function isValidRepoName(repo: string): boolean {
+ return /^[\w.-]+\/[\w.-]+$/.test(repo);
+}
+
+export function isValidBranchName(branch: string): boolean {
+ return (
+ branch.length > 0 &&
+ branch.length <= 80 &&
+ /^[\w./-]+$/.test(branch) &&
+ !branch.includes("..") &&
+ !branch.startsWith("/") &&
+ !branch.endsWith("/") &&
+ !branch.endsWith(".lock")
+ );
+}
+
+/** Repo-relative file path: no traversal, no .git, no absolute paths. */
+export function isSafeRepoPath(path: string): boolean {
+ if (!path || path.length > 200) return false;
+ if (!/^[\w./ -]+$/.test(path)) return false;
+ const segments = path.split("/");
+ if (segments.some((seg) => !seg || seg === "." || seg === "..")) return false;
+ if (segments[0] === ".git") return false;
+ return true;
+}
+
+export function validatePushFiles(files: PushFile[]): { ok: true } | { ok: false; error: string } {
+ if (files.length === 0) return { ok: false, error: "no files to push" };
+ if (files.length > MAX_PUSH_FILES) {
+ return { ok: false, error: `too many files — max ${MAX_PUSH_FILES} per push` };
+ }
+
+ let total = 0;
+ const seen = new Set();
+ for (const f of files) {
+ if (!isSafeRepoPath(f.path)) return { ok: false, error: `unsafe file path: ${f.path || "(empty)"}` };
+ if (seen.has(f.path)) return { ok: false, error: `duplicate file path: ${f.path}` };
+ seen.add(f.path);
+ if (!f.content) return { ok: false, error: `empty content for ${f.path}` };
+ if (f.content.length > MAX_FILE_CHARS) {
+ return { ok: false, error: `${f.path} is too large — max ${MAX_FILE_CHARS} characters per file` };
+ }
+ total += f.content.length;
+ }
+ if (total > MAX_TOTAL_CHARS) {
+ return { ok: false, error: `push too large — max ${MAX_TOTAL_CHARS} characters total` };
+ }
+ return { ok: true };
+}
diff --git a/src/lib/supabase/config.ts b/src/lib/supabase/config.ts
index df54450..bfd6b32 100644
--- a/src/lib/supabase/config.ts
+++ b/src/lib/supabase/config.ts
@@ -4,10 +4,13 @@ export type SupabasePublicConfig = {
};
export function getSupabasePublicConfig(): SupabasePublicConfig {
- const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ // Accept both the NEXT_PUBLIC_* names and the names the Supabase Vercel
+ // integration creates, so auth works regardless of which set is configured.
+ 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.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ??
+ process.env.SUPABASE_ANON_KEY;
if (!url || !anonKey) {
throw new Error(
diff --git a/src/lib/validation.ts b/src/lib/validation.ts
index 7a5d7f9..ebb5d23 100644
--- a/src/lib/validation.ts
+++ b/src/lib/validation.ts
@@ -58,6 +58,12 @@ export const ProjectCreateSchema = z.object({
.string()
.regex(/^#[0-9a-fA-F]{6}$/, "Color must be a 6-digit hex code")
.optional(),
+ sourceRepo: z
+ .string()
+ .max(140)
+ .regex(/^[\w.-]+\/[\w.-]+$/, 'Repo must be in "owner/name" form, e.g. durga710/rayhealth-evv-platform')
+ .nullable()
+ .optional(),
});
export const ProjectUpdateSchema = ProjectCreateSchema.partial().omit({ slug: true });
diff --git a/src/middleware.ts b/src/middleware.ts
index 715c553..a91976e 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,5 +1,9 @@
import { NextResponse, type NextRequest } from "next/server";
-import { isAllowedOperatorEmail } from "@/lib/auth-policy";
+import {
+ isAuthRoute,
+ isProtectedRoute,
+ shouldRedirectAuthenticatedToDashboard,
+} from "@/lib/auth-route-policy";
import {
responseWithAuthCookies,
updateSupabaseSession,
@@ -12,17 +16,12 @@ import {
* plus the sign-in / sign-up flows themselves.
* - Protected: everything under /dashboard, /admin, and any /api/private routes.
*
- * Supabase SSR middleware refreshes the auth cookie, validates the user server-side,
- * and redirects unauthenticated requests to the app-owned sign-in page.
+ * Supabase SSR middleware refreshes the auth cookie and validates the user
+ * server-side. Authentication is enforced here; membership (owner email or an
+ * enrolled team member) requires a database lookup, so requireUser() enforces
+ * it inside the Node runtime for every protected query.
*/
-const protectedPrefixes = ["/dashboard", "/admin", "/api/private"];
-const authPrefixes = ["/sign-in", "/sign-up"];
-
-function matchesPrefix(pathname: string, prefixes: string[]): boolean {
- return prefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
-}
-
function redirectWithSessionCookies(
request: NextRequest,
source: NextResponse,
@@ -32,17 +31,17 @@ function redirectWithSessionCookies(
}
export default async function middleware(request: NextRequest) {
- const { pathname, search } = request.nextUrl;
- const isProtectedRoute = matchesPrefix(pathname, protectedPrefixes);
- const isAuthRoute = matchesPrefix(pathname, authPrefixes);
+ const { pathname, search, searchParams } = request.nextUrl;
+ const protectedRoute = isProtectedRoute(pathname);
+ const authRoute = isAuthRoute(pathname);
- if (!isProtectedRoute && !isAuthRoute) {
+ if (!protectedRoute && !authRoute) {
return NextResponse.next();
}
const { response, user, error } = await updateSupabaseSession(request);
- if (isProtectedRoute) {
+ if (protectedRoute) {
const next = encodeURIComponent(`${pathname}${search}`);
if (error || !user) {
@@ -57,14 +56,10 @@ export default async function middleware(request: NextRequest) {
);
}
- if (!isAllowedOperatorEmail(user.email)) {
- return redirectWithSessionCookies(request, response, "/sign-in?error=not-allowed");
- }
-
return response;
}
- if (user && isAllowedOperatorEmail(user.email)) {
+ if (user && shouldRedirectAuthenticatedToDashboard(pathname, searchParams)) {
return redirectWithSessionCookies(request, response, "/dashboard");
}
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..9ad0331
--- /dev/null
+++ b/style.css
@@ -0,0 +1,65 @@
+body {
+ margin: 0;
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ background-color: #f8f9fc;
+ color: #333;
+}
+
+.hero {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ min-height: 100vh;
+ background: linear-gradient(135deg, #0072ff, #00c6ff);
+ color: #fff;
+ padding: 0 1rem;
+}
+
+.content {
+ max-width: 600px;
+}
+
+h1 {
+ font-size: 2.5rem;
+ margin-bottom: 1rem;
+}
+
+p {
+ line-height: 1.5;
+ margin-bottom: 2rem;
+}
+
+form {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+input[type='email'] {
+ padding: 0.8rem 1rem;
+ border: none;
+ border-radius: 4px;
+ width: 250px;
+ font-size: 1rem;
+}
+
+button {
+ padding: 0.8rem 1.2rem;
+ background-color: #fff;
+ color: #0072ff;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.3s;
+}
+
+button:hover {
+ background-color: #e5e5e5;
+}
+
+#response-message {
+ margin-top: 1rem;
+ font-weight: bold;
+}
diff --git a/tests/auth-policy.test.ts b/tests/auth-policy.test.ts
new file mode 100644
index 0000000..04372b4
--- /dev/null
+++ b/tests/auth-policy.test.ts
@@ -0,0 +1,122 @@
+import assert from "node:assert/strict";
+import { afterEach, describe, it } from "node:test";
+import {
+ canEnrollWithInvite,
+ DEFAULT_OPERATOR_EMAIL,
+ getTeamInviteCodes,
+ isAllowedOperatorEmail,
+ isValidTeamInviteCode,
+ normalizeEmail,
+ safeRedirectPath,
+} from "../src/lib/auth-policy";
+
+const originalAllowedEmail = process.env.AUTH_ALLOWED_EMAIL;
+const originalInviteCodes = process.env.AUTH_TEAM_INVITE_CODES;
+
+function restoreEnv(key: string, value: string | undefined) {
+ if (value === undefined) {
+ delete process.env[key];
+ } else {
+ process.env[key] = value;
+ }
+}
+
+afterEach(() => {
+ restoreEnv("AUTH_ALLOWED_EMAIL", originalAllowedEmail);
+ restoreEnv("AUTH_TEAM_INVITE_CODES", originalInviteCodes);
+});
+
+describe("normalizeEmail", () => {
+ it("lowercases and trims", () => {
+ assert.equal(normalizeEmail(" Rey@Example.COM "), "rey@example.com");
+ });
+
+ it("returns empty string for null/undefined", () => {
+ assert.equal(normalizeEmail(null), "");
+ assert.equal(normalizeEmail(undefined), "");
+ });
+});
+
+describe("isAllowedOperatorEmail", () => {
+ it("accepts the configured owner email case-insensitively", () => {
+ process.env.AUTH_ALLOWED_EMAIL = "owner@example.com";
+ assert.equal(isAllowedOperatorEmail("Owner@Example.com"), true);
+ });
+
+ it("rejects other emails", () => {
+ process.env.AUTH_ALLOWED_EMAIL = "owner@example.com";
+ assert.equal(isAllowedOperatorEmail("teammate@example.com"), false);
+ });
+
+ it("falls back to the default operator email", () => {
+ delete process.env.AUTH_ALLOWED_EMAIL;
+ assert.equal(isAllowedOperatorEmail(DEFAULT_OPERATOR_EMAIL), true);
+ });
+});
+
+describe("getTeamInviteCodes", () => {
+ it("parses a comma-separated list with whitespace", () => {
+ process.env.AUTH_TEAM_INVITE_CODES = " alpha-1 , beta-2 ,, gamma-3 ";
+ assert.deepEqual(getTeamInviteCodes(), ["alpha-1", "beta-2", "gamma-3"]);
+ });
+
+ it("returns an empty list when unset", () => {
+ delete process.env.AUTH_TEAM_INVITE_CODES;
+ assert.deepEqual(getTeamInviteCodes(), []);
+ });
+});
+
+describe("isValidTeamInviteCode", () => {
+ it("accepts a configured code, ignoring surrounding whitespace", () => {
+ process.env.AUTH_TEAM_INVITE_CODES = "alpha-1,beta-2";
+ assert.equal(isValidTeamInviteCode(" beta-2 "), true);
+ });
+
+ it("rejects unknown, empty, and missing codes", () => {
+ process.env.AUTH_TEAM_INVITE_CODES = "alpha-1";
+ assert.equal(isValidTeamInviteCode("wrong"), false);
+ assert.equal(isValidTeamInviteCode(""), false);
+ assert.equal(isValidTeamInviteCode(null), false);
+ assert.equal(isValidTeamInviteCode(undefined), false);
+ });
+
+ it("rejects everything when no codes are configured", () => {
+ delete process.env.AUTH_TEAM_INVITE_CODES;
+ assert.equal(isValidTeamInviteCode("anything"), false);
+ });
+});
+
+describe("canEnrollWithInvite", () => {
+ it("lets the owner enroll without an invite code", () => {
+ process.env.AUTH_ALLOWED_EMAIL = "owner@example.com";
+ process.env.AUTH_TEAM_INVITE_CODES = "alpha-1";
+ assert.equal(canEnrollWithInvite("owner@example.com", undefined), true);
+ });
+
+ it("lets a teammate enroll with a valid invite code", () => {
+ process.env.AUTH_ALLOWED_EMAIL = "owner@example.com";
+ process.env.AUTH_TEAM_INVITE_CODES = "alpha-1";
+ assert.equal(canEnrollWithInvite("teammate@example.com", "alpha-1"), true);
+ });
+
+ it("rejects a teammate without a valid invite code", () => {
+ process.env.AUTH_ALLOWED_EMAIL = "owner@example.com";
+ process.env.AUTH_TEAM_INVITE_CODES = "alpha-1";
+ assert.equal(canEnrollWithInvite("teammate@example.com", "nope"), false);
+ assert.equal(canEnrollWithInvite("teammate@example.com", undefined), false);
+ });
+});
+
+describe("safeRedirectPath", () => {
+ it("defaults to /dashboard for non-strings and unsafe values", () => {
+ assert.equal(safeRedirectPath(null), "/dashboard");
+ assert.equal(safeRedirectPath("https://evil.example"), "/dashboard");
+ assert.equal(safeRedirectPath("//evil.example"), "/dashboard");
+ assert.equal(safeRedirectPath("/sign-in?next=/dashboard"), "/dashboard");
+ assert.equal(safeRedirectPath("/sign-up"), "/dashboard");
+ });
+
+ it("keeps safe internal paths", () => {
+ assert.equal(safeRedirectPath("/dashboard/projects"), "/dashboard/projects");
+ });
+});
diff --git a/tests/auth-route-policy.test.ts b/tests/auth-route-policy.test.ts
new file mode 100644
index 0000000..1bd296f
--- /dev/null
+++ b/tests/auth-route-policy.test.ts
@@ -0,0 +1,78 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ isAuthRoute,
+ isProtectedRoute,
+ matchesRoutePrefix,
+ shouldRedirectAuthenticatedToDashboard,
+} from "../src/lib/auth-route-policy";
+
+describe("matchesRoutePrefix", () => {
+ it("matches exact prefixes and nested paths only", () => {
+ assert.equal(matchesRoutePrefix("/dashboard", ["/dashboard"]), true);
+ assert.equal(matchesRoutePrefix("/dashboard/tasks", ["/dashboard"]), true);
+ assert.equal(matchesRoutePrefix("/dashboards", ["/dashboard"]), false);
+ });
+});
+
+describe("isProtectedRoute", () => {
+ it("protects dashboard, admin, and private API routes", () => {
+ assert.equal(isProtectedRoute("/dashboard"), true);
+ assert.equal(isProtectedRoute("/admin/users"), true);
+ assert.equal(isProtectedRoute("/api/private/notes"), true);
+ });
+
+ it("leaves marketing and auth routes public", () => {
+ assert.equal(isProtectedRoute("/"), false);
+ assert.equal(isProtectedRoute("/projects/ghimtech"), false);
+ assert.equal(isProtectedRoute("/sign-in"), false);
+ });
+});
+
+describe("isAuthRoute", () => {
+ it("matches sign-in and sign-up routes", () => {
+ assert.equal(isAuthRoute("/sign-in"), true);
+ assert.equal(isAuthRoute("/sign-up/step"), true);
+ assert.equal(isAuthRoute("/dashboard"), false);
+ });
+});
+
+describe("shouldRedirectAuthenticatedToDashboard", () => {
+ it("redirects authenticated sessions from clean auth routes", () => {
+ assert.equal(
+ shouldRedirectAuthenticatedToDashboard("/sign-in", new URLSearchParams()),
+ true
+ );
+ assert.equal(
+ shouldRedirectAuthenticatedToDashboard(
+ "/sign-in",
+ new URLSearchParams("next=%2Fdashboard")
+ ),
+ true
+ );
+ });
+
+ it("keeps auth routes with an error reachable to avoid redirect loops", () => {
+ assert.equal(
+ shouldRedirectAuthenticatedToDashboard(
+ "/sign-in",
+ new URLSearchParams("error=not-allowed")
+ ),
+ false
+ );
+ assert.equal(
+ shouldRedirectAuthenticatedToDashboard(
+ "/sign-up",
+ new URLSearchParams("error=invalid-invite")
+ ),
+ false
+ );
+ });
+
+ it("never redirects non-auth routes", () => {
+ assert.equal(
+ shouldRedirectAuthenticatedToDashboard("/dashboard", new URLSearchParams()),
+ false
+ );
+ });
+});
diff --git a/tests/repo-files.test.ts b/tests/repo-files.test.ts
new file mode 100644
index 0000000..a458074
--- /dev/null
+++ b/tests/repo-files.test.ts
@@ -0,0 +1,83 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ isSafeRepoPath,
+ isValidBranchName,
+ isValidRepoName,
+ validatePushFiles,
+ MAX_FILE_CHARS,
+ MAX_PUSH_FILES,
+} from "../src/lib/repo-files";
+
+describe("isValidRepoName", () => {
+ it("accepts owner/name forms", () => {
+ assert.equal(isValidRepoName("durga710/rayhealth-evv-platform"), true);
+ assert.equal(isValidRepoName("a-b/c.d"), true);
+ });
+
+ it("rejects everything else", () => {
+ assert.equal(isValidRepoName("no-slash"), false);
+ assert.equal(isValidRepoName("a/b/c"), false);
+ assert.equal(isValidRepoName("owner/"), false);
+ assert.equal(isValidRepoName("https://github.com/a/b"), false);
+ });
+});
+
+describe("isValidBranchName", () => {
+ it("accepts normal branch names", () => {
+ assert.equal(isValidBranchName("copilot/app-xyz"), true);
+ assert.equal(isValidBranchName("feature/landing.v2"), true);
+ });
+
+ it("rejects traversal, slashes at edges, and lock suffix", () => {
+ assert.equal(isValidBranchName("a..b"), false);
+ assert.equal(isValidBranchName("/lead"), false);
+ assert.equal(isValidBranchName("trail/"), false);
+ assert.equal(isValidBranchName("x".repeat(81)), false);
+ assert.equal(isValidBranchName("name.lock"), false);
+ assert.equal(isValidBranchName(""), false);
+ });
+});
+
+describe("isSafeRepoPath", () => {
+ it("accepts nested repo-relative paths", () => {
+ assert.equal(isSafeRepoPath("index.html"), true);
+ assert.equal(isSafeRepoPath("src/components/App.tsx"), true);
+ assert.equal(isSafeRepoPath(".github/workflows/ci.yml"), true);
+ });
+
+ it("rejects traversal, absolute paths, and .git", () => {
+ assert.equal(isSafeRepoPath("../escape.txt"), false);
+ assert.equal(isSafeRepoPath("a/../b"), false);
+ assert.equal(isSafeRepoPath("/etc/passwd"), false);
+ assert.equal(isSafeRepoPath(".git/config"), false);
+ assert.equal(isSafeRepoPath("a//b"), false);
+ assert.equal(isSafeRepoPath(""), false);
+ });
+});
+
+describe("validatePushFiles", () => {
+ const file = (path: string, content = "x") => ({ path, content });
+
+ it("accepts a normal file set", () => {
+ assert.deepEqual(
+ validatePushFiles([file("index.html"), file("src/main.ts"), file("package.json")]),
+ { ok: true },
+ );
+ });
+
+ it("rejects empty sets, oversized sets, and duplicates", () => {
+ assert.equal(validatePushFiles([]).ok, false);
+ assert.equal(
+ validatePushFiles(Array.from({ length: MAX_PUSH_FILES + 1 }, (_, i) => file(`f${i}.txt`))).ok,
+ false,
+ );
+ assert.equal(validatePushFiles([file("a.txt"), file("a.txt")]).ok, false);
+ });
+
+ it("rejects unsafe paths, empty content, and oversized files", () => {
+ assert.equal(validatePushFiles([file("../x")]).ok, false);
+ assert.equal(validatePushFiles([file("a.txt", "")]).ok, false);
+ assert.equal(validatePushFiles([file("a.txt", "y".repeat(MAX_FILE_CHARS + 1))]).ok, false);
+ });
+});