From 04d6176bf6586f3ffb6bae6824f722b7ea26a71f Mon Sep 17 00:00:00 2001 From: Iago Prieto Lamas <50492345+IagoPL@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:00:49 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(auth):=20mejorar=20estados=20de=20inici?= =?UTF-8?q?o=20de=20sesi=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La UI pública de login no debe mostrar detalles de infraestructura ni nombres de variables. --- e2e/smoke.spec.ts | 22 +++- src/app/auth/callback/route.ts | 17 ++- src/app/auth/error/page.tsx | 55 ++++---- src/app/login/page.tsx | 43 ++----- src/features/authentication/actions.ts | 13 +- .../authentication/github-sign-in-button.tsx | 53 ++++++++ .../authentication/login-screen.test.tsx | 121 ++++++++++++++++++ src/features/authentication/login-screen.tsx | 66 ++++++++++ .../authentication/sign-in-errors.test.ts | 19 +++ src/features/authentication/sign-in-errors.ts | 12 ++ src/i18n/dictionaries/en.ts | 10 +- src/i18n/dictionaries/es.ts | 11 +- src/i18n/dictionaries/login-copy.test.ts | 45 +++++++ 13 files changed, 421 insertions(+), 66 deletions(-) create mode 100644 src/features/authentication/github-sign-in-button.tsx create mode 100644 src/features/authentication/login-screen.test.tsx create mode 100644 src/features/authentication/login-screen.tsx create mode 100644 src/features/authentication/sign-in-errors.test.ts create mode 100644 src/features/authentication/sign-in-errors.ts create mode 100644 src/i18n/dictionaries/login-copy.test.ts diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index b388dab..801cba5 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -26,9 +26,25 @@ test.describe("smoke", () => { await expect( page.getByRole("heading", { name: /Sign in|Iniciar sesión/i }), ).toBeVisible(); - await expect( - page.getByRole("button", { name: /Continue with GitHub|Continuar con GitHub/i }), - ).toBeVisible(); + const github = page.getByRole("button", { + name: /Continue with GitHub|Continuar con GitHub/i, + }); + await expect(github).toBeVisible(); + const body = await page.locator("main").innerText(); + expect(body).not.toMatch( + /NEXT_PUBLIC_SUPABASE|Supabase Auth|proxy de Next\.js|anon key/i, + ); + if (!(await github.isEnabled())) { + await expect( + page + .getByRole("status") + .or( + page.getByText( + /Sign in is temporarily unavailable|El inicio de sesión no está disponible temporalmente/i, + ), + ), + ).toBeVisible(); + } }); test("language switcher is available on landing", async ({ page }) => { diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index dc0e709..30ca089 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -1,7 +1,17 @@ import { NextResponse } from "next/server"; import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { PUBLIC_SIGN_IN_ERROR } from "@/features/authentication/sign-in-errors"; import { safeRedirectPath } from "@/features/authentication/safe-redirect"; +function signInErrorUrl(origin: string, next: string) { + const url = new URL("/login", origin); + url.searchParams.set("error", PUBLIC_SIGN_IN_ERROR); + if (next && next !== "/app") { + url.searchParams.set("next", next); + } + return url; +} + export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url); const code = searchParams.get("code"); @@ -10,7 +20,10 @@ export async function GET(request: Request) { if (code) { const supabase = await createSupabaseServerClient(); if (!supabase) { - return NextResponse.redirect(`${origin}/auth/error?reason=supabase-not-configured`); + if (process.env.NODE_ENV === "development") { + console.warn("Auth environment is incomplete."); + } + return NextResponse.redirect(signInErrorUrl(origin, next)); } const { error } = await supabase.auth.exchangeCodeForSession(code); @@ -28,5 +41,5 @@ export async function GET(request: Request) { } } - return NextResponse.redirect(`${origin}/auth/error?reason=auth-code`); + return NextResponse.redirect(signInErrorUrl(origin, next)); } diff --git a/src/app/auth/error/page.tsx b/src/app/auth/error/page.tsx index 07f4eb4..cffda6d 100644 --- a/src/app/auth/error/page.tsx +++ b/src/app/auth/error/page.tsx @@ -1,43 +1,52 @@ import Link from "next/link"; +import { LanguageSwitcher } from "@/components/shared/language-switcher"; +import { ThemeToggle } from "@/components/shared/theme-toggle"; import { buttonVariants } from "@/components/ui/button"; +import { getDictionary, getLocale } from "@/i18n/get-dictionary"; import { cn } from "@/lib/utils"; export const metadata = { - title: "Authentication error", + title: "Sign-in failed", }; -const reasons: Record = { - "auth-code": "We could not complete the GitHub sign-in callback.", - "oauth-start": "GitHub OAuth could not be started. Check Supabase provider settings.", - "supabase-not-configured": - "Supabase environment variables are missing. Copy .env.example to .env.local and configure your project.", -}; - -export default async function AuthErrorPage({ - searchParams, -}: { - searchParams: Promise<{ reason?: string }>; -}) { - const params = await searchParams; - const reason = params.reason ?? "auth-code"; - const message = reasons[reason] ?? reasons["auth-code"]; +export default async function AuthErrorPage() { + const locale = await getLocale(); + const t = await getDictionary(locale); return ( -
+
+
+ + {t.common.brand} + +
+ + +
+
+
-

- Error +

+ {t.login.failedTitle} +

+

+ {t.login.error}

-

Sign-in failed

-

{message}

- Back to sign in + {t.login.backToSignIn} - Home + {t.login.home}
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index c91eac8..9c47d17 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,12 +1,12 @@ import Link from "next/link"; import { LanguageSwitcher } from "@/components/shared/language-switcher"; import { ThemeToggle } from "@/components/shared/theme-toggle"; -import { buttonVariants } from "@/components/ui/button"; +import { LoginScreen } from "@/features/authentication/login-screen"; +import { isPublicSignInError } from "@/features/authentication/sign-in-errors"; import { signInWithGitHub } from "@/features/authentication/actions"; import { safeRedirectPath } from "@/features/authentication/safe-redirect"; import { getDictionary, getLocale } from "@/i18n/get-dictionary"; import { isSupabaseConfigured } from "@/lib/supabase/config"; -import { cn } from "@/lib/utils"; export const metadata = { title: "Sign in", @@ -22,7 +22,11 @@ export default async function LoginPage({ const t = await getDictionary(locale); const configured = isSupabaseConfigured(); const next = safeRedirectPath(params.next); - const showConfigError = params.error === "supabase-not-configured" || !configured; + const showError = configured && isPublicSignInError(params.error); + + if (!configured && process.env.NODE_ENV === "development") { + console.warn("Auth environment is incomplete."); + } return (
@@ -43,32 +47,13 @@ export default async function LoginPage({ -
-
-

{t.login.title}

-

{t.login.body}

-
- - {showConfigError ? ( -

- {t.login.configWarning} -

- ) : null} - -
- - -
-
+
); } diff --git a/src/features/authentication/actions.ts b/src/features/authentication/actions.ts index 5cbea6f..7fe91ab 100644 --- a/src/features/authentication/actions.ts +++ b/src/features/authentication/actions.ts @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { isSupabaseConfigured } from "@/lib/supabase/config"; +import { PUBLIC_SIGN_IN_ERROR } from "@/features/authentication/sign-in-errors"; import { safeRedirectPath } from "@/features/authentication/safe-redirect"; function appOrigin() { @@ -11,12 +12,18 @@ function appOrigin() { export async function signInWithGitHub(formData: FormData) { if (!isSupabaseConfigured()) { - redirect("/login?error=supabase-not-configured"); + if (process.env.NODE_ENV === "development") { + console.warn("Auth environment is incomplete."); + } + redirect("/login"); } const supabase = await createSupabaseServerClient(); if (!supabase) { - redirect("/login?error=supabase-not-configured"); + if (process.env.NODE_ENV === "development") { + console.warn("Auth environment is incomplete."); + } + redirect("/login"); } const next = safeRedirectPath(String(formData.get("next") ?? "/app")); @@ -29,7 +36,7 @@ export async function signInWithGitHub(formData: FormData) { }); if (error || !data.url) { - redirect("/auth/error?reason=oauth-start"); + redirect(`/login?error=${PUBLIC_SIGN_IN_ERROR}`); } redirect(data.url); diff --git a/src/features/authentication/github-sign-in-button.tsx b/src/features/authentication/github-sign-in-button.tsx new file mode 100644 index 0000000..c92259f --- /dev/null +++ b/src/features/authentication/github-sign-in-button.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useFormStatus } from "react-dom"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function GitHubSignInControl({ + idleLabel, + pendingLabel, + disabled = false, + pending = false, +}: { + idleLabel: string; + pendingLabel: string; + disabled?: boolean; + pending?: boolean; +}) { + const isDisabled = disabled || pending; + const showPending = pending && !disabled; + + return ( + + ); +} + +export function GitHubSignInButton({ + idleLabel, + pendingLabel, + disabled = false, +}: { + idleLabel: string; + pendingLabel: string; + disabled?: boolean; +}) { + const { pending } = useFormStatus(); + return ( + + ); +} diff --git a/src/features/authentication/login-screen.test.tsx b/src/features/authentication/login-screen.test.tsx new file mode 100644 index 0000000..a9f6e78 --- /dev/null +++ b/src/features/authentication/login-screen.test.tsx @@ -0,0 +1,121 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { GitHubSignInControl } from "@/features/authentication/github-sign-in-button"; +import { LoginScreen } from "@/features/authentication/login-screen"; +import { en } from "@/i18n/dictionaries/en"; +import { es } from "@/i18n/dictionaries/es"; + +function renderLogin( + options: { + locale?: "en" | "es"; + configured?: boolean; + showError?: boolean; + } = {}, +) { + const locale = options.locale ?? "en"; + const copy = locale === "es" ? es.login : en.login; + return render( + undefined} + />, + ); +} + +const leakPatterns = [ + /NEXT_PUBLIC_SUPABASE/i, + /SUPABASE_SERVICE_ROLE/i, + /Supabase Auth/i, + /Supabase aún/i, + /proxy de Next\.js/i, + /Next\.js proxy/i, + /anon key/i, + /publishable key/i, + /publishable\/anon/i, + /\.env\.local/i, +]; + +function assertNoTechnicalLeaks(container: HTMLElement) { + const text = container.textContent ?? ""; + for (const pattern of leakPatterns) { + expect(text).not.toMatch(pattern); + } +} + +describe("LoginScreen", () => { + afterEach(() => { + cleanup(); + }); + + it("enables the GitHub button when auth is ready", () => { + renderLogin({ configured: true, locale: "en" }); + expect(screen.getByRole("button", { name: en.login.continueGithub })).toBeEnabled(); + expect(screen.getByRole("heading", { name: en.login.title })).toBeVisible(); + expect(screen.getByText(en.login.body)).toBeVisible(); + expect(screen.queryByText(en.login.unavailable)).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + assertNoTechnicalLeaks(document.body); + }); + + it("disables the GitHub button and shows a human message when auth is not ready", () => { + renderLogin({ configured: false, locale: "es" }); + expect(screen.getByRole("button", { name: es.login.continueGithub })).toBeDisabled(); + expect(screen.getByRole("status")).toHaveTextContent(es.login.unavailable); + expect(screen.queryByText(es.login.body)).not.toBeInTheDocument(); + assertNoTechnicalLeaks(document.body); + }); + + it("never surfaces configuration internals in production copy", () => { + renderLogin({ configured: false, locale: "en" }); + expect(screen.getByText(en.login.unavailable)).toBeVisible(); + assertNoTechnicalLeaks(document.body); + }); + + it("renders Spanish login copy", () => { + renderLogin({ configured: true, locale: "es" }); + expect(screen.getByRole("heading", { name: "Iniciar sesión" })).toBeVisible(); + expect( + screen.getByText("Continúa con GitHub para acceder a HubForge."), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Continuar con GitHub" })).toBeEnabled(); + }); + + it("renders English login copy", () => { + renderLogin({ configured: true, locale: "en" }); + expect(screen.getByRole("heading", { name: "Sign in" })).toBeVisible(); + expect(screen.getByText("Continue with GitHub to access HubForge.")).toBeVisible(); + expect(screen.getByRole("button", { name: "Continue with GitHub" })).toBeEnabled(); + }); + + it("shows a human error without implementation details", () => { + renderLogin({ configured: true, showError: true, locale: "en" }); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent(en.login.error); + assertNoTechnicalLeaks(document.body); + }); +}); + +describe("GitHubSignInControl loading state", () => { + afterEach(() => { + cleanup(); + }); + + it("prevents a second submit and keeps the control size stable", () => { + render( +
+ + , + ); + const button = screen.getByRole("button", { name: en.login.connectingGithub }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("aria-busy", "true"); + expect(button).toHaveTextContent("Connecting to GitHub…"); + }); +}); diff --git a/src/features/authentication/login-screen.tsx b/src/features/authentication/login-screen.tsx new file mode 100644 index 0000000..e3ac471 --- /dev/null +++ b/src/features/authentication/login-screen.tsx @@ -0,0 +1,66 @@ +import { GitHubSignInButton } from "@/features/authentication/github-sign-in-button"; + +export type LoginCopy = { + title: string; + body: string; + continueGithub: string; + connectingGithub: string; + unavailable: string; + error: string; +}; + +export function LoginScreen({ + copy, + configured, + showError, + next, + action, +}: { + copy: LoginCopy; + configured: boolean; + showError: boolean; + next: string; + action: (formData: FormData) => void | Promise; +}) { + const statusId = configured ? undefined : "login-unavailable"; + const errorId = showError ? "login-error" : undefined; + const describedBy = [errorId, statusId].filter(Boolean).join(" ") || undefined; + + return ( +
+
+

{copy.title}

+ {configured ? ( +

{copy.body}

+ ) : ( +

+ {copy.unavailable} +

+ )} +
+ + {showError ? ( + + ) : null} + +
+ + + +
+ ); +} diff --git a/src/features/authentication/sign-in-errors.test.ts b/src/features/authentication/sign-in-errors.test.ts new file mode 100644 index 0000000..81804d6 --- /dev/null +++ b/src/features/authentication/sign-in-errors.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { + isPublicSignInError, + PUBLIC_SIGN_IN_ERROR, +} from "@/features/authentication/sign-in-errors"; + +describe("isPublicSignInError", () => { + it("treats known auth failures as a generic sign-in error", () => { + expect(isPublicSignInError(PUBLIC_SIGN_IN_ERROR)).toBe(true); + expect(isPublicSignInError("oauth-start")).toBe(true); + expect(isPublicSignInError("auth-code")).toBe(true); + expect(isPublicSignInError("supabase-not-configured")).toBe(true); + }); + + it("ignores unrelated query values", () => { + expect(isPublicSignInError(undefined)).toBe(false); + expect(isPublicSignInError("next")).toBe(false); + }); +}); diff --git a/src/features/authentication/sign-in-errors.ts b/src/features/authentication/sign-in-errors.ts new file mode 100644 index 0000000..7fb20e1 --- /dev/null +++ b/src/features/authentication/sign-in-errors.ts @@ -0,0 +1,12 @@ +export const PUBLIC_SIGN_IN_ERROR = "signin"; + +const SIGN_IN_ERROR_PARAMS = new Set([ + PUBLIC_SIGN_IN_ERROR, + "oauth-start", + "auth-code", + "supabase-not-configured", +]); + +export function isPublicSignInError(error: string | undefined) { + return Boolean(error && SIGN_IN_ERROR_PARAMS.has(error)); +} diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index fcf7d7f..7560d8d 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -111,10 +111,14 @@ export const en = { }, login: { title: "Sign in", - body: "Continue with GitHub through Supabase Auth. Sessions are cookie-based and refreshed by the Next.js proxy.", + body: "Continue with GitHub to access HubForge.", continueGithub: "Continue with GitHub", - configWarning: - "Supabase is not configured yet. Add NEXT_PUBLIC_SUPABASE_URL and a publishable/anon key to .env.local, then enable the GitHub provider.", + connectingGithub: "Connecting to GitHub…", + unavailable: "Sign in is temporarily unavailable. Please try again later.", + error: "We couldn't sign you in with GitHub. Please try again.", + failedTitle: "Sign-in failed", + backToSignIn: "Back to sign in", + home: "Home", }, app: { openTasks: "Open work", diff --git a/src/i18n/dictionaries/es.ts b/src/i18n/dictionaries/es.ts index d5c35f3..9a6b08d 100644 --- a/src/i18n/dictionaries/es.ts +++ b/src/i18n/dictionaries/es.ts @@ -110,10 +110,15 @@ export const es = { }, login: { title: "Iniciar sesión", - body: "Continúa con GitHub mediante Supabase Auth. Las sesiones van en cookies y se renuevan con el proxy de Next.js.", + body: "Continúa con GitHub para acceder a HubForge.", continueGithub: "Continuar con GitHub", - configWarning: - "Supabase aún no está configurado. Añade NEXT_PUBLIC_SUPABASE_URL y una clave publishable/anon en .env.local y activa el proveedor de GitHub.", + connectingGithub: "Conectando con GitHub…", + unavailable: + "El inicio de sesión no está disponible temporalmente. Inténtalo de nuevo más tarde.", + error: "No hemos podido iniciar sesión con GitHub. Inténtalo de nuevo.", + failedTitle: "No se pudo iniciar sesión", + backToSignIn: "Volver a iniciar sesión", + home: "Inicio", }, app: { openTasks: "Trabajo abierto", diff --git a/src/i18n/dictionaries/login-copy.test.ts b/src/i18n/dictionaries/login-copy.test.ts new file mode 100644 index 0000000..9e2613f --- /dev/null +++ b/src/i18n/dictionaries/login-copy.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { en } from "@/i18n/dictionaries/en"; +import { es } from "@/i18n/dictionaries/es"; + +const forbidden = [ + "NEXT_PUBLIC_SUPABASE", + "SUPABASE_SERVICE_ROLE", + "Supabase Auth", + "Supabase aún", + "proxy de Next.js", + "Next.js proxy", + "anon key", + "publishable key", + "publishable/anon", + ".env.local", +]; + +function collectLoginCopy() { + return [...Object.values(en.login), ...Object.values(es.login)].join("\n"); +} + +describe("public login copy", () => { + it("does not mention Auth implementation details", () => { + const text = collectLoginCopy(); + for (const token of forbidden) { + expect(text).not.toContain(token); + } + }); + + it("keeps the requested Spanish and English phrasing", () => { + expect(es.login.title).toBe("Iniciar sesión"); + expect(es.login.body).toBe("Continúa con GitHub para acceder a HubForge."); + expect(es.login.continueGithub).toBe("Continuar con GitHub"); + expect(es.login.connectingGithub).toBe("Conectando con GitHub…"); + expect(es.login.unavailable).toContain("no está disponible temporalmente"); + expect(es.login.error).toContain("No hemos podido iniciar sesión con GitHub"); + + expect(en.login.title).toBe("Sign in"); + expect(en.login.body).toBe("Continue with GitHub to access HubForge."); + expect(en.login.continueGithub).toBe("Continue with GitHub"); + expect(en.login.connectingGithub).toBe("Connecting to GitHub…"); + expect(en.login.unavailable).toContain("temporarily unavailable"); + expect(en.login.error).toContain("couldn't sign you in with GitHub"); + }); +}); From d91382e4b4d6d470e556a75e117e88c07e739212 Mon Sep 17 00:00:00 2001 From: Iago Prieto Lamas <50492345+IagoPL@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:01:17 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(operations):=20actualizar=20preparaci?= =?UTF-8?q?=C3=B3n=20de=20staging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alinea el checklist con Auth OAuth, GitHub App y la migración de check runs sin afirmar estado remoto no verificado. --- docs/operations/github-app-setup.md | 22 +++++-- docs/operations/production-checklist.md | 78 ++++++++++++++++--------- docs/operations/supabase-auth-setup.md | 5 +- 3 files changed, 69 insertions(+), 36 deletions(-) diff --git a/docs/operations/github-app-setup.md b/docs/operations/github-app-setup.md index 8014dbf..5bd706f 100644 --- a/docs/operations/github-app-setup.md +++ b/docs/operations/github-app-setup.md @@ -1,11 +1,13 @@ # Configuración de GitHub App (sincronización de repositorios) -HubForge sincroniza issues, pull requests y commits mediante una **GitHub App** (no personal access tokens). El login de auth sigue siendo Supabase GitHub OAuth y es independiente. +HubForge sincroniza issues, pull requests, commits y check runs mediante una **GitHub App** (no personal access tokens). + +Eso es independiente del **login de usuarios**: el inicio de sesión usa una OAuth App de GitHub configurada en Supabase Authentication. Credenciales, callbacks y eventos no se comparten entre ambos sistemas. Rutas de sincronización: 1. **Webhooks** — actualizaciones incrementales cuando GitHub dispara eventos -2. **API backfill** — tras vincular (o mediante **Sync now**), HubForge usa el JWT de la App + installation token para importar issues recientes (~50), PRs (~40) y commits (~40) +2. **API backfill** — tras vincular (o mediante **Sync now**), HubForge usa el JWT de la App + installation token para importar issues recientes (~50), PRs (~40), commits (~40) y check runs (best-effort) ## 1. Crear la GitHub App @@ -19,9 +21,12 @@ Rutas de sincronización: - Metadata: Read-only - Pull requests: Read-only - Contents: Read-only (commit metadata) -7. Subscribe to events: `Issues`, `Pull request`, `Push`, `Installation`, `Installation repositories` + - Checks: Read-only (check runs / check suites) +7. Subscribe to events: `Issues`, `Pull request`, `Push`, `Installation`, `Installation repositories`, `Check run`, `Check suite` 8. Crear la app y anotar App ID, Client ID, Client secret; generar una private key +El estado live de eventos y permisos de la App **no está verificado** en esta auditoría. No la modifiques todavía. + ## 2. Entorno ```bash @@ -38,9 +43,13 @@ SUPABASE_SERVICE_ROLE_KEY= ## 3. Base de datos -Aplica `supabase/migrations/20260728240000_github_app_sync.sql` y -`supabase/migrations/20260803200000_operations_history_deps_github_activity.sql` -después de las migraciones anteriores de HubForge. +Aplica, en orden y solo cuando se autorice, las migraciones de GitHub App: + +1. `supabase/migrations/20260728240000_github_app_sync.sql` +2. `supabase/migrations/20260803200000_operations_history_deps_github_activity.sql` +3. `supabase/migrations/20260804120000_github_synced_check_runs.sql` + +La migración `20260804120000_github_synced_check_runs.sql` está en el repositorio. **No está confirmada en el proyecto remoto** hasta listarla con la CLI de Supabase autenticada. No la apliques en esta fase de auditoría. ## 4. Vincular un repositorio @@ -52,5 +61,6 @@ después de las migraciones anteriores de HubForge. 6. Abre/cierra un issue en GitHub; HubForge hace upsert en `github_synced_issues` y refleja una tarea de HubForge 7. Abre o actualiza un pull request; HubForge hace upsert en `github_synced_pull_requests` 8. Haz push de commits; HubForge hace upsert de filas en `github_synced_commits` +9. Tras aplicar la migración de check runs y suscribir `Check run` / `Check suite`, HubForge hace upsert en `github_synced_check_runs` (best-effort; un fallo no tumba el resto de la sync) Sin credenciales de App o un installation id, aún puedes vincular un repositorio para mostrarlo, pero el API backfill y **Sync now** permanecen deshabilitados hasta que ambos estén configurados. diff --git a/docs/operations/production-checklist.md b/docs/operations/production-checklist.md index f565675..fd3adf1 100644 --- a/docs/operations/production-checklist.md +++ b/docs/operations/production-checklist.md @@ -1,51 +1,69 @@ # Checklist de producción (MVP usable con env) Dominio de producción objetivo: `https://hubforge-six.vercel.app` -Proyecto Supabase: `pnpkgfhpwvdkhbncfwqz` (eu-west-1) — esquema/migraciones ya aplicadas. +Proyecto Supabase: `pnpkgfhpwvdkhbncfwqz` (eu-west-1) -## Estado actual (instantánea local) +Auth de usuario (GitHub OAuth vía Supabase) y la GitHub App (sincronización de repositorios) son sistemas independientes. Configurar uno no habilita el otro. -| Pieza | Estado | -| ------------------------------------------------------ | ------------------------------ | -| Supabase Postgres + tablas RLS | Hecho | -| `NEXT_PUBLIC_SUPABASE_*` + `NEXT_PUBLIC_APP_URL` local | Presentes en `.env.local` | -| `SUPABASE_SERVICE_ROLE_KEY` | Ausente localmente | -| Env de GitHub App (`GITHUB_APP_*`, webhook secret) | Ausente localmente | -| Proyecto Vercel `hubforge` | Vinculado; dominio prod activo | -| Resend / Sentry | Opcional | +## Estado conocido (auditoría de staging) -Ejecutar localmente: +Esta tabla describe lo verificado en auditoría, no un estado de lanzamiento. + +| Pieza | Estado conocido | +| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Código en `main` con `/api/ready` y sync de check runs | Integrado en el repositorio | +| Despliegue en `https://hubforge-six.vercel.app` | Desactualizado respecto a `main` (`GET /api/ready` responde 404) | +| `.env.local` en esta máquina de trabajo | Ausente — flags de auth locales en MISS | +| Env de Vercel Production (nombres) | No listado: CLI/MCP de Vercel sin autenticar en esta máquina | +| GitHub OAuth (Supabase Authentication → GitHub) | No verificado en dashboard — no modificar todavía | +| Redirect URLs de Auth | Deben incluir `http://localhost:3000/auth/callback` y `https://hubforge-six.vercel.app/auth/callback` — no verificado en dashboard | +| Migración `20260804120000_github_synced_check_runs.sql` | Presente en el repo; **aplicación remota no verificada** | +| Eventos GitHub App `Check run` / `Check suite` | Requeridos por el código; estado live de la App no verificado | + +Nunca imprimas valores de secretos. Usa `pnpm verify:env` (nombres y OK/MISS). ```bash pnpm verify:env curl -s http://localhost:3000/api/ready ``` -## 1. Auth (requerido para un MVP usable) +En producción, cuando el deploy esté al día: + +```bash +curl -s https://hubforge-six.vercel.app/api/ready +``` -1. Supabase → Authentication → Providers → GitHub habilitado con credenciales de OAuth App +## 1. Auth (login de usuarios — OAuth GitHub) + +No confundir con la GitHub App. + +1. Supabase → Authentication → Providers → GitHub habilitado con credenciales de **OAuth App** (no App de sincronización) 2. URL Configuration: - Site URL: `https://hubforge-six.vercel.app` - Redirect URLs incluyen: - `http://localhost:3000/auth/callback` - `https://hubforge-six.vercel.app/auth/callback` -3. `.env.local` / env de Vercel: - - `NEXT_PUBLIC_SUPABASE_URL=https://pnpkgfhpwvdkhbncfwqz.supabase.co` - - `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=…` - - `NEXT_PUBLIC_APP_URL=https://hubforge-six.vercel.app` (prod) o `http://localhost:3000` (local) -4. Verificar: iniciar sesión en `/login` → aterrizar en `/app` +3. Env local y Vercel (Production + Preview): + - `NEXT_PUBLIC_SUPABASE_URL` + - `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` (o fallback `NEXT_PUBLIC_SUPABASE_ANON_KEY`) + - `NEXT_PUBLIC_APP_URL` — origen público usado en `redirectTo` de OAuth +4. Verificar: `/login` muestra el botón de GitHub habilitado, sin avisos técnicos, y el flujo aterriza en `/app` -## 2. Sincronización con GitHub (requerido para la capacidad MVP completa #8) +`SUPABASE_SERVICE_ROLE_KEY` no es necesaria para el login; sí lo es para sync. -1. Crear GitHub App — `docs/operations/github-app-setup.md` +## 2. Sincronización con GitHub (GitHub App) + +1. Crear/actualizar GitHub App — `docs/operations/github-app-setup.md` 2. Webhook URL: `https://hubforge-six.vercel.app/api/github/webhooks` 3. Setup URL: `https://hubforge-six.vercel.app/api/github/setup` -4. Configurar en `.env.local` **y** Vercel (Production + Preview): - - `SUPABASE_SERVICE_ROLE_KEY` (Supabase → Project Settings → API) +4. Eventos: Issues, Pull request, Push, Installation, Installation repositories, **Check run**, **Check suite** +5. Configurar en `.env.local` **y** Vercel (Production + Preview): + - `SUPABASE_SERVICE_ROLE_KEY` - `GITHUB_APP_ID`, `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET` - - `GITHUB_APP_PRIVATE_KEY` (PEM con escapes `\n` o secreto multilínea) + - `GITHUB_APP_PRIVATE_KEY` - `GITHUB_APP_SLUG`, `GITHUB_WEBHOOK_SECRET` -5. Verificar: vincular `owner/repo` con installation id → **Sync now** rellena issues/PRs/commits +6. Base de datos: además de las migraciones anteriores, existe `supabase/migrations/20260804120000_github_synced_check_runs.sql`. **No afirmar que está aplicada en remoto hasta listar migraciones con la CLI autenticada.** No aplicar todavía desde esta fase. +7. Verificar: vincular `owner/repo` con installation id → **Sync now** rellena issues/PRs/commits (check runs son best-effort) ## 3. Opcional @@ -54,13 +72,17 @@ curl -s http://localhost:3000/api/ready ## 4. Desplegar -1. Fusionar PR con el trabajo de preparación -2. Confirmar que el env de Vercel Production coincide con la lista anterior +No desplegar hasta terminar la auditoría de staging y tener Auth + env de producción listos. + +1. Fusionar el trabajo de preparación cuando se autorice +2. Confirmar que el env de Vercel Production coincide con las variables de Auth (y de App si la sync entra en el lanzamiento) 3. Redesplegar si se añadió env después del último deploy -4. Smoke: `/` → `/login` → `/app` → crear tarea → enlace de invitación en Team → GitHub Sync now +4. Smoke: `/` → `/login` (copy pública, sin variables de entorno) → `/app` → crear tarea → enlace de invitación en Team → GitHub Sync now +5. `GET /api/ready` en producción debe devolver JSON con `authReady` (y `githubReady` si aplica) ## Listo cuando - `pnpm verify:env` termina con código 0 para los flags de auth -- `/api/ready` devuelve `"authReady": true` en el entorno desplegado +- `/api/ready` en el entorno desplegado devuelve `"authReady": true` - Los flags de sincronización con GitHub son true si la sync de repositorios está en el alcance del lanzamiento +- El login público no menciona Supabase, cookies, proxy ni nombres de variables diff --git a/docs/operations/supabase-auth-setup.md b/docs/operations/supabase-auth-setup.md index 914b165..fcc3570 100644 --- a/docs/operations/supabase-auth-setup.md +++ b/docs/operations/supabase-auth-setup.md @@ -76,8 +76,9 @@ O pega estos archivos en el editor SQL, en orden: 5. `supabase/migrations/20260728240000_github_app_sync.sql` 6. `supabase/migrations/20260728250000_chat_realtime.sql` 7. `supabase/migrations/20260803200000_operations_history_deps_github_activity.sql` +8. `supabase/migrations/20260804120000_github_synced_check_runs.sql` (presente en el repo; aplicación remota no verificada en la auditoría de staging) -Consulta también `docs/operations/github-app-setup.md` y `docs/operations/production-checklist.md`. +Consulta también `docs/operations/github-app-setup.md` y `docs/operations/production-checklist.md`. No apliques migraciones remotas hasta que se autorice. ## 7. Verificar @@ -88,4 +89,4 @@ Consulta también `docs/operations/github-app-setup.md` y `docs/operations/produ 5. Aterriza en `/app` con tu nombre en la cabecera 6. Sign out -Sin variables de entorno de Supabase, el botón de inicio de sesión permanece deshabilitado y `/app` redirige a `/login`. No hay workspace demo offline. +Sin Auth configurada, `/login` muestra un estado neutro (“no disponible temporalmente”), el botón de GitHub permanece deshabilitado y `/app` redirige a `/login`. La UI pública no debe mostrar nombres de variables ni detalles de Supabase. No hay workspace demo offline.