From 7d93af69990a72d0c246c44dbc3b7d5395671b16 Mon Sep 17 00:00:00 2001 From: Pedro Paulo Vezza Campos Date: Mon, 1 Jun 2026 21:27:58 -0700 Subject: [PATCH 1/2] test(login): add failing test for gh-auth-token PAT tip Renders the login page with dev auto-login disabled, reveals the PAT section, and asserts an info-icon tooltip pointing at `gh auth token` appears next to the field. Fails at this commit (product code not yet added): TestingLibraryElementError: Unable to find an accessible element with the role "button" and name /Tip: paste the output of gh auth token/i Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/login/page.integration.test.tsx | 51 +++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/app/login/page.integration.test.tsx diff --git a/src/app/login/page.integration.test.tsx b/src/app/login/page.integration.test.tsx new file mode 100644 index 00000000..ed3e4da4 --- /dev/null +++ b/src/app/login/page.integration.test.tsx @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@/tests/helpers'; +import LoginPage from './page'; + +const validateToken = vi.fn(); +const clearError = vi.fn(); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: vi.fn() }), + useSearchParams: () => ({ get: () => null }), +})); + +vi.mock('@/features/auth/stores/useAuthStore', () => ({ + useAuthStore: () => ({ + validateToken, + error: null, + isValidating: false, + clearError, + }), +})); + +vi.mock('@/features/auth/hooks', () => ({ + useOAuthFlow: () => ({ initiateOAuth: vi.fn(), isInitiating: false }), + useRedirectIfAuthenticated: () => ({ isAuthenticated: false }), + // 'disabled' = not a local dev build → renders the normal OAuth/PAT UI. + useDevAutoLogin: () => 'disabled', +})); + +describe('LoginPage PAT gh-auth-token tip', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('reveals an info tip pointing at `gh auth token` next to the PAT field', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /Personal Access Token/i })); + + const tip = screen.getByRole('button', { + name: /Tip: paste the output of gh auth token/i, + }); + expect(tip).toBeInTheDocument(); + + await user.hover(tip); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent(/gh auth token/i); + }); +}); From faed7a988d19b7f92c622dbf3df6ab9d26495025 Mon Sep 17 00:00:00 2001 From: Pedro Paulo Vezza Campos Date: Mon, 1 Jun 2026 21:28:09 -0700 Subject: [PATCH 2/2] feat(login): info-icon tip for gh-auth-token PAT auth + WONTFIX preview domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an accessible info-icon tooltip next to the Personal Access Token field on the login page: "Tip: paste the output of gh auth token". The PAT field already accepts gho_ tokens, so the gh CLI token works verbatim — handy on PR previews, where OAuth can't complete. Why: Cloudflare hard-locks preview URLs to *.workers.dev and won't map a custom *.codjiflo.net preview domain (migration task 2.6 WONTFIX). On a workers.dev origin the cross-subdomain OAuth flow can't complete (host-only PKCE cookies + isValidReturnOrigin rejects the origin). The PAT field is origin-independent (validateToken only hits api.github.com), so it is the supported preview-auth path. - Re-export Tooltip/TooltipTrigger from @/components/ui (react-aria) - .label-with-tip / .field-tip(-trigger) styles in controls.css - Document the PAT preview-auth path in authentication/architecture.md (new "Preview-Environment Auth" section; correct the stale pr-{n}.codjiflo.net claim) and resolve task 2.6 + design.md notes Test from the prior commit now passes (red→green). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migrate-deploy-to-cloudflare/design.md | 4 +- .../migrate-deploy-to-cloudflare/tasks.md | 4 +- openspec/specs/authentication/architecture.md | 39 +++++++++++++--- src/app/login/page.tsx | 20 ++++++++- src/components/ui/index.ts | 2 + src/styles/shared/controls.css | 45 +++++++++++++++++++ 6 files changed, 103 insertions(+), 11 deletions(-) diff --git a/openspec/changes/migrate-deploy-to-cloudflare/design.md b/openspec/changes/migrate-deploy-to-cloudflare/design.md index 512f0db5..0af76420 100644 --- a/openspec/changes/migrate-deploy-to-cloudflare/design.md +++ b/openspec/changes/migrate-deploy-to-cloudflare/design.md @@ -31,7 +31,7 @@ Connect the repo in the Cloudflare dashboard so Cloudflare builds on push and ma ### Domain cutover to `codjiflo.net` Production = `https://codjiflo.net`. Replace `codjiflo.vza.net` in CI health checks, `E2E_BASE_URL`, auth `KNOWN_BASE_DOMAIN` (→ `.codjiflo.net`), GitHub App homepage + callback URLs, and `architecture.md`. -- **PR previews are served under a custom `*.codjiflo.net` preview domain** (if Cloudflare supports mapping one for non-production deployments), so previews share the `.codjiflo.net` cookie domain and cross-subdomain auth keeps working. If a custom preview domain proves unavailable and previews fall back to `*.workers.dev`, the consequence is login-per-preview (cookies don't span domains) — acceptable but not preferred. Either way CI reads the preview URL from the GitHub deployment status `target_url`, so it stays scheme-agnostic. +- **PR previews:** the custom `*.codjiflo.net` preview domain proved **impossible** — Cloudflare hard-locks preview URLs to `*.workers.dev` (task 2.6 WONTFIX). On a `workers.dev` origin OAuth doesn't complete (host-only PKCE cookies + `isValidReturnOrigin()` rejects the origin), so previews authenticate via the **origin-independent PAT field** instead (paste `gh auth token`); unauthenticated public-PR review also works. CI reads the preview URL from the Workers Builds check output, so it stays scheme-agnostic. - The old domain gets a **301 redirect** `codjiflo.vza.net → codjiflo.net`. `vza.net` lives in a **different Cloudflare account**, so this redirect is configured there, not in the app's account — it requires switching/re-logging into that account (a manual, off-band step that needs the user to authorize the account change). ### Commit SHA source for `/api/health` @@ -49,7 +49,7 @@ The Cloudflare GitHub integration and the `main` branch-protection required chec ## Risks / Trade-offs - **Preview hostname scheme differs from Vercel's `pr-{n}` pattern** → CI already reads `target_url` from the GitHub deployment status, so it stays scheme-agnostic; only auth's `KNOWN_BASE_DOMAIN` needs the new apex. -- **Cross-subdomain cookie auth breaks if previews are not under `*.codjiflo.net`** → decision is to configure a custom preview domain under `codjiflo.net`; if Cloudflare can't map one and previews land on `*.workers.dev`, previews won't share the auth cookie domain → fall back to login-per-preview. +- **Cross-subdomain cookie auth breaks if previews are not under `*.codjiflo.net`** → confirmed: Cloudflare can't map a custom preview domain (task 2.6 WONTFIX), so previews land on `*.workers.dev` and OAuth doesn't complete there. Resolved by the origin-independent PAT path (paste `gh auth token`) for preview auth. - **OpenNext adapter incompatibility with current `next.config.ts` (webpack/turbopack WASM for SQL.js)** → validate the OpenNext build locally before cutover; SQL.js runs client-side, so server build should be unaffected, but verify. - **DNS/SSL cutover gap on `codjiflo.net`** → stage DNS + Worker route before flipping CI health URLs; verify `/api/health` on the new domain first. - **Required check wedges merges if misconfigured** → enable the required Cloudflare check only after a green deploy is observed on a test PR. diff --git a/openspec/changes/migrate-deploy-to-cloudflare/tasks.md b/openspec/changes/migrate-deploy-to-cloudflare/tasks.md index d7d7093f..54863145 100644 --- a/openspec/changes/migrate-deploy-to-cloudflare/tasks.md +++ b/openspec/changes/migrate-deploy-to-cloudflare/tasks.md @@ -13,8 +13,8 @@ - [x] 2.3 Uploaded `GITHUB_APP_CLIENT_SECRET` (from `.env.local`) into the default Secret Store off-band via `printf '%s' … | wrangler secrets-store secret create` (stdin, value never echoed). This is the only app secret; the E2E token is test-time only and is NOT uploaded - [x] 2.4 No `.env.local` on disk (confirmed absent). Local dev no longer needs it at all: prod-mode E2E reads `gh auth token` and local manual dev auto-signs-in via the dev-only `/api/auth/dev-token` route — the client secret is exercised only in PR previews/prod (see PR #535) - [x] 2.5 Non-secret config — **no dashboard vars needed**. `GITHUB_APP_CLIENT_ID` is a plain Worker runtime `var` in `wrangler.jsonc` (server-side). The three build-inlined values are computed in `next.config.ts` `env` with in-repo defaults: `NEXT_PUBLIC_GITHUB_CLIENT_ID` (`Iv23liUEkzCUSR78IkHn`), `NEXT_PUBLIC_APP_URL` (`https://codjiflo.net` in prod, `http://localhost:3000` in dev), and `APP_COMMIT_SHA` (from `WORKERS_CI_COMMIT_SHA` / `git rev-parse`). Verified: `next build` inlines the real HEAD SHA into the health-route bundle -- [ ] 2.6 Map `codjiflo.net` (DNS + Worker route + SSL) to the production Worker; configure a custom `*.codjiflo.net` preview domain for non-production deployments so previews keep the `.codjiflo.net` cookie domain (fall back to `*.workers.dev` = login-per-preview only if a custom preview domain isn't supported) -- [x] 2.7 Confirmed on PR #530 itself: Cloudflare builds + deploys a preview. It does **NOT** post a GitHub deployment/environment — it posts the `Workers Builds: codjiflo` check run carrying `Preview URL` (`https://-codjiflo.vezza-dev.workers.dev`) and `Preview Alias URL` (`https://-codjiflo.vezza-dev.workers.dev`). Previews are on `*.vezza-dev.workers.dev` (custom `*.codjiflo.net` preview domain still pending, task 2.6 — workers.dev fallback = login-per-preview, fine for CI). **Ruleset implication (task 5.1):** the old `required_deployments: ["Preview"]` gate can never be met (no deployment env); require the `Workers Builds: codjiflo` check instead +- [x] 2.6 **Production mapping DONE; custom preview domain WONTFIX.** `codjiflo.net` (DNS + Worker route + SSL) maps to the production Worker and serves live (verified in 2.1 / 6.3). The custom `*.codjiflo.net` preview domain is **not possible**: Cloudflare hard-locks preview (version/alias) URLs to `*.workers.dev` and does not allow mapping them to a custom domain. Consequence: cross-subdomain OAuth doesn't complete on a `workers.dev` preview origin (host-only PKCE cookies + `isValidReturnOrigin()` rejects the origin). This is **acceptable** — preview auth uses the **origin-independent PAT field** (paste `gh auth token`, a `gho_` token, accepted directly; no cookies/callback/prod dependency), and CI prod-mode E2E seeds its own token. Documented in [authentication/architecture.md](../../specs/authentication/architecture.md) "Preview-Environment Auth (PAT path)"; login page shows an info-icon tip next to the PAT field +- [x] 2.7 Confirmed on PR #530 itself: Cloudflare builds + deploys a preview. It does **NOT** post a GitHub deployment/environment — it posts the `Workers Builds: codjiflo` check run carrying `Preview URL` (`https://-codjiflo.vezza-dev.workers.dev`) and `Preview Alias URL` (`https://-codjiflo.vezza-dev.workers.dev`). Previews are on `*.vezza-dev.workers.dev` (custom `*.codjiflo.net` preview domain is impossible — task 2.6 WONTFIX; preview auth uses the origin-independent PAT path, fine for CI). **Ruleset implication (task 5.1):** the old `required_deployments: ["Preview"]` gate can never be met (no deployment env); require the `Workers Builds: codjiflo` check instead ## 3. Application & config repoint diff --git a/openspec/specs/authentication/architecture.md b/openspec/specs/authentication/architecture.md index 4d565012..2dfc0e2f 100644 --- a/openspec/specs/authentication/architecture.md +++ b/openspec/specs/authentication/architecture.md @@ -20,22 +20,51 @@ GitHub App with OAuth 2.0 and PKCE (not a standalone OAuth App). Supports cross- | `src/app/auth/callback/page.tsx` | OAuth callback handler | | `src/app/auth/landing/page.tsx` | Cross-subdomain token hydration | -## Cross-Subdomain Flow (PR Previews) +## Cross-Subdomain Flow (`*.codjiflo.net`) -PR previews run on `pr-{number}.codjiflo.net` (Cloudflare custom preview domain under the `codjiflo.net` apex). GitHub OAuth only allows specific callback URLs, so all callbacks go through `codjiflo.net`, then redirect back. +Any origin under the `codjiflo.net` apex (production, plus any `*.codjiflo.net` +subdomain) can sign in cross-subdomain. GitHub OAuth only allows specific +callback URLs, so all callbacks go through `codjiflo.net`, then redirect back. ``` -pr-123.codjiflo.net (click login) +foo.codjiflo.net (click login) ↓ store return origin cookie (domain=.codjiflo.net) GitHub OAuth ↓ codjiflo.net/auth/callback ↓ exchange code, store token in transfer cookie -pr-123.codjiflo.net/auth/landing +foo.codjiflo.net/auth/landing ↓ hydrate token from cookie /dashboard ``` +The flow relies on `domain=.codjiflo.net` cookies and `isValidReturnOrigin()` +accepting the origin, so it only works on `localhost` and `*.codjiflo.net`. + +## Preview-Environment Auth (PAT path) + +Cloudflare Workers Builds serves PR previews on **`*.workers.dev`** version/alias +URLs (e.g. `https://-codjiflo.vezza-dev.workers.dev`). Cloudflare hard-locks +preview URLs to `workers.dev` — they **cannot** be mapped to a `*.codjiflo.net` +custom domain. On a `workers.dev` origin the OAuth flow above does **not** complete: +the PKCE/return-origin cookies fall back to host-only (no `.codjiflo.net` domain to +share) and `isValidReturnOrigin()` rejects the non-`codjiflo.net` origin, so the +callback can't hand the token back. + +The supported way to authenticate on a preview is the **Personal Access Token +field** on the login page ("Use Personal Access Token"). It is **origin-independent**: +`validateToken` only calls `GET https://api.github.com/rate_limit` with the token as +a bearer and persists it to `localStorage` — no cookies, no callback, no production +dependency. It accepts any of `ghp_`, `github_pat_`, `gho_`, or `ghs_` prefixes, so +the quickest source is **`gh auth token`** (a `gho_` GitHub CLI OAuth token) pasted +straight in — no PAT needs to be minted. The login page surfaces this as an info-icon +tip next to the PAT field. Unauthenticated public-PR review also works on previews +with no token at all. + +> **`pr-{n}.codjiflo.net` custom preview domains were investigated and dropped** +> (migration task 2.6, WONTFIX) — Cloudflare does not allow it. CI prod-mode E2E +> seeds its own token and is unaffected; manual preview auth uses the PAT path above. + ## Cookie Strategy | Cookie | Purpose | TTL | @@ -99,7 +128,7 @@ NEXT_PUBLIC_APP_URL = https://codjiflo.net # preview & prod 2. Set Homepage URL: `https://codjiflo.net` 3. Under "Identifying and authorizing users", add callback URLs: - `http://localhost:3000/auth/callback` (local dev) - - `https://codjiflo.net/auth/callback` (production + PR previews under `*.codjiflo.net`) + - `https://codjiflo.net/auth/callback` (production; previews use the PAT path, see above) 4. Set required permissions (see table below) 5. Copy Client ID → `GITHUB_APP_CLIENT_ID` and `NEXT_PUBLIC_GITHUB_CLIENT_ID` 6. Generate Client Secret → `GITHUB_APP_CLIENT_SECRET` diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index e935132a..26e31eba 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -6,9 +6,10 @@ import Image from 'next/image'; import { useAuthStore } from '@/features/auth/stores/useAuthStore'; import { useOAuthFlow, useRedirectIfAuthenticated, useDevAutoLogin } from '@/features/auth/hooks'; import { isValidReturnPath } from '@/features/auth/utils/pkce'; -import { TextField, Label, Input, Text } from '@/components/ui'; +import { TextField, Label, Input, Text, Tooltip, TooltipTrigger } from '@/components/ui'; import { Button } from '@/components/Button'; import { AppShell } from '@/components/layout'; +import { Info } from 'lucide-react'; function LoginContent() { const [tokenInput, setTokenInput] = useState(''); @@ -125,7 +126,22 @@ function LoginContent() { isRequired className="form-group" > - +
+ + + + + Tip: paste the output of gh auth token + + +
on hover/focus (keyboard accessible, unlike the + CSS-only .tooltip above). */ +.label-with-tip { + display: flex; + align-items: center; + gap: 4px; +} + +.field-tip-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px; + min-width: 0; + line-height: 0; + color: var(--watermark-text); + background: transparent; + border: none; +} + +.field-tip-trigger:hover, +.field-tip-trigger[data-hovered], +.field-tip-trigger[data-focus-visible] { + color: var(--main-fg); +} + +.field-tip { + padding: 6px 10px; + background-color: var(--contextmenu-bg); + color: var(--main-fg); + font-size: 12px; + border: 1px solid var(--combobox-border); + max-width: 260px; + z-index: 1000; +} + +.field-tip code { + font-family: var(--font-mono, monospace); + font-size: 11px; + padding: 1px 4px; + background-color: var(--menu-hover); + border-radius: 3px; +} + /* ============================================ SCROLLABLE CONTAINER ============================================ */