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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions openspec/changes/migrate-deploy-to-cloudflare/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions openspec/changes/migrate-deploy-to-cloudflare/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<version>-codjiflo.vezza-dev.workers.dev`) and `Preview Alias URL` (`https://<branch>-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://<version>-codjiflo.vezza-dev.workers.dev`) and `Preview Alias URL` (`https://<branch>-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

Expand Down
39 changes: 34 additions & 5 deletions openspec/specs/authentication/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<branch>-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 |
Expand Down Expand Up @@ -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`
Expand Down
51 changes: 51 additions & 0 deletions src/app/login/page.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LoginPage />);

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);
});
});
20 changes: 18 additions & 2 deletions src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down Expand Up @@ -125,7 +126,22 @@ function LoginContent() {
isRequired
className="form-group"
>
<Label className="label">Personal Access Token</Label>
<div className="label-with-tip">
<Label className="label">Personal Access Token</Label>
<TooltipTrigger delay={200}>
<Button
variant="ghost"
type="button"
className="field-tip-trigger"
aria-label="Tip: paste the output of gh auth token"
>
<Info size={14} aria-hidden="true" />
</Button>
<Tooltip className="field-tip" placement="top">
Tip: paste the output of <code>gh auth token</code>
</Tooltip>
</TooltipTrigger>
</div>
<Input
id="pat"
className="textbox"
Expand Down
2 changes: 2 additions & 0 deletions src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export {
SearchField,
Popover,
OverlayArrow,
Tooltip,
TooltipTrigger,
Input,
Label,
TextField,
Expand Down
45 changes: 45 additions & 0 deletions src/styles/shared/controls.css
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,51 @@
visibility: visible;
}

/* Inline field hint: an info icon next to a field label that reveals a
react-aria <Tooltip> 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
============================================ */
Expand Down
Loading