diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 5e4de53..9b7f7cb 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -57,7 +57,7 @@ jobs:
- name: Run Storybook tests
run: pnpm test:storybook
- - name: Run six Playwright browser checks
+ - name: Run Playwright browser checks
run: pnpm test:e2e
- name: Upload Playwright report on failure
diff --git a/README.md b/README.md
index 7fd38c9..2a574f5 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ pnpm dev
Open `http://localhost:3000`.
-Populate `.env.local` with the development values from the Clerk dashboard. Never commit `.env.local` or real secrets.
+Populate `.env.local` with development values from the Clerk dashboard. Never commit `.env.local` or real secrets.
```dotenv
NEXT_PUBLIC_APP_URL=http://localhost:3000
@@ -44,13 +44,58 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_replace_me
CLERK_SECRET_KEY=sk_test_replace_me
```
-`NEXT_PUBLIC_API_BASE_URL` points to the local APISIX gateway.
+`NEXT_PUBLIC_API_BASE_URL` points to the local APISIX gateway. The checked-in `.env.example` intentionally contains placeholders only.
-## Clerk bootstrap behavior
+## Authentication configuration
-`src/app/layout.tsx` installs `ClerkProvider`, and `src/proxy.ts` installs `clerkMiddleware()` when both Clerk keys are real values.
+`src/lib/clerk-config.ts` classifies the Clerk key pair as one of:
-The checked-in placeholders intentionally keep Clerk inactive so dependency installation, static builds, Storybook, and public foundation smoke tests can run without secrets. This is bootstrap behavior only. Protected product routes must be added explicitly and must fail closed before authenticated features are shipped.
+- `configured` — both keys follow Clerk's documented formats and belong to the same test or live environment;
+- `missing` — one or both values are absent or blank;
+- `placeholder` — checked-in example values or other obvious placeholders are present;
+- `malformed` — key formats are invalid or test/live environments do not match.
+
+A Publishable Key is validated as a `pk_test_` or `pk_live_` value containing a base64-encoded Frontend API value with Clerk's trailing `$` delimiter. A Secret Key is treated as opaque after its documented `sk_test_` or `sk_live_` prefix and requires only a non-empty payload; the frontend does not impose an undocumented charset or length.
+
+`src/lib/clerk-config.server.ts` is the only environment-reading boundary. It validates the secret but never returns, logs, renders, or serializes it. `src/lib/clerk-config.server.ts` and `src/lib/auth-session.server.ts` both import `server-only`, so Next.js rejects either module when it is pulled into a Client Component. The root layout receives only the publishable key when configuration is valid.
+
+Secretless builds, public pages, Storybook, unit tests, and public browser tests continue to work. Protected routes never become public when Clerk is unavailable:
+
+| Route | Policy when Clerk is configured | Policy when Clerk is unavailable |
+| --- | --- | --- |
+| `/` | Public | Public |
+| `/sign-in/[[...sign-in]]` | Clerk sign-in; signed-in users return to `/app` | Safe configuration state |
+| `/sign-up/[[...sign-up]]` | Clerk sign-up; signed-in users return to `/app` | Safe configuration state |
+| `/app` and `/app/**` | Authentication required | HTTP 503 fail-closed response |
+| `/api/**` | No global policy; each future route defines its own boundary | No global policy |
+
+The proxy provides an early redirect for unauthenticated document requests. The protected layout and protected page both repeat the server-side Clerk session check. This defense in depth is intentional because a layout check alone is not sufficient for every client-side navigation or future server resource.
+
+## Application shell
+
+The current protected shell provides only one real navigation entry:
+
+```text
+Overview → /app
+```
+
+It includes:
+
+- a skip link and semantic header, navigation, and main landmarks;
+- a keyboard-accessible Radix mobile navigation sheet with Escape handling and focus return;
+- persistent navigation from 1024 px upward;
+- constrained content width at large viewports;
+- a Clerk `UserButton` with stable loading dimensions;
+- loading, configuration unavailable, session unavailable, and unexpected error states;
+- reduced-motion behavior and visible focus states.
+
+No jobs, talent, organizations, marketplace, applications, billing, messaging, fake metrics, fake users, or invented backend calls are included.
+
+## Backend authentication contract
+
+The backend verifies Clerk session JWTs at the service boundary. Browser requests must send `Authorization: Bearer ` through APISIX. APISIX forwards the header, applies browser CORS and `X-Request-Id`, and does not perform JWT verification itself.
+
+This frontend PR does not call a backend endpoint. A future API client must preserve `X-Request-Id`, parse the backend error envelope `{code,message,request_id,details}`, and map account and dependency states without exposing raw backend errors.
## Commands
@@ -58,16 +103,41 @@ The checked-in placeholders intentionally keep Clerk inactive so dependency inst
pnpm dev # local development
pnpm lint # ESLint
pnpm typecheck # TypeScript
-pnpm test # Vitest
+pnpm test # unit Vitest project
pnpm build # production build
pnpm storybook # Storybook development server
pnpm build-storybook # static Storybook build
pnpm test:storybook # Storybook component tests
-pnpm test:e2e # local Playwright tests
+pnpm test:e2e # local Playwright tests in three browsers
pnpm check # lint, typecheck, unit test, app build, Storybook build
```
-Playwright starts the local Next.js server automatically. Local runs cover Chromium, Firefox, and WebKit. CI uses Chromium for a bounded cross-commit smoke check.
+Playwright starts the local Next.js server automatically and never visits an external website.
+
+Without a dedicated Clerk test instance, CI covers:
+
+- public routes;
+- configuration classification;
+- public/protected pathname policy;
+- presentation-only shell rendering and keyboard navigation;
+- deterministic auth-unavailable pages;
+- protected-route HTTP 503 fail-closed behavior;
+- axe checks across public and unavailable states.
+
+CI does not claim an authenticated end-to-end Clerk redirect or sign-in flow without actual Clerk test credentials.
+
+### Manual authenticated smoke checklist
+
+**Status: pending.** This checklist has not been executed for this PR because no dedicated Clerk test instance and credentials were available in the automated environment.
+
+- [ ] A signed-out request to `/app` redirects to `/sign-in` with a valid return path.
+- [ ] The real Clerk sign-in component renders without a configuration or network error.
+- [ ] Successful sign-in returns the user to `/app`.
+- [ ] Refreshing `/app` preserves the authenticated session.
+- [ ] The `UserButton` opens and signing out completes successfully.
+- [ ] After sign-out, accessing `/app` is protected again and returns to the sign-in flow.
+- [ ] `.env.local` remains ignored and is not staged or committed.
+- [ ] Browser HTML, console output, network payloads, and client bundles contain no `CLERK_SECRET_KEY` value or other server secret.
## Agent instructions and UI skills
@@ -82,7 +152,7 @@ BridgeWorks-specific rules, verified backend contracts, and the existing design
## Repository boundaries
- Do not invent backend endpoints.
-- Verify routes, auth, status, error-envelope, CORS, and request-ID contracts from the backend `main` branch.
+- Verify routes, auth, status, error-envelope, CORS, and request-ID contracts from backend `main`.
- Keep generic primitives in `src/components/ui`.
- Keep feature-specific code in `src/features`.
- Prefer Server Components and keep Client Components small.
@@ -95,9 +165,9 @@ The frontend workflow validates:
- frozen dependency installation;
- lint and typecheck;
-- Vitest;
+- Vitest unit tests;
- Next.js production build;
- Storybook build and Storybook tests;
-- Playwright smoke and axe checks.
+- Playwright smoke, fail-closed, and axe checks across Chromium, Firefox, and WebKit.
Playwright reports are retained only when the workflow fails.
diff --git a/package.json b/package.json
index 758bfb2..197f3ee 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
- "test": "vitest run --passWithNoTests",
+ "test": "vitest run --project=unit --passWithNoTests",
"test:storybook": "vitest --project=storybook --run --passWithNoTests",
"test:e2e": "playwright test",
"storybook": "storybook dev -p 6006",
diff --git a/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
new file mode 100644
index 0000000..d627ca4
--- /dev/null
+++ b/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
@@ -0,0 +1,82 @@
+import { SignIn } from "@clerk/nextjs";
+import type { Metadata } from "next";
+import { redirect } from "next/navigation";
+
+import {
+ AuthComponentFallback,
+ AuthPage,
+} from "@/components/layout/auth-page";
+import {
+ AuthState,
+ configurationStateKind,
+} from "@/components/layout/auth-state";
+import { getClerkSessionState } from "@/lib/auth-session.server";
+import {
+ APP_ROUTE,
+ SIGN_IN_ROUTE,
+ SIGN_UP_ROUTE,
+} from "@/lib/auth-routes";
+import { getClerkConfiguration } from "@/lib/clerk-config.server";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = {
+ title: "Sign in",
+ description: "Sign in to the protected BridgeWorks application.",
+};
+
+export default async function SignInPage() {
+ const configuration = getClerkConfiguration();
+
+ if (configuration.status !== "configured") {
+ return (
+
+
+
+ );
+ }
+
+ const session = await getClerkSessionState();
+ if (session.status === "signed-in") {
+ redirect(APP_ROUTE);
+ }
+ if (session.status === "session-unavailable") {
+ return (
+
+
+
+ );
+ }
+ if (session.status === "unexpected") {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ }
+ />
+
+ );
+}
diff --git a/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx
new file mode 100644
index 0000000..9e60d92
--- /dev/null
+++ b/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx
@@ -0,0 +1,82 @@
+import { SignUp } from "@clerk/nextjs";
+import type { Metadata } from "next";
+import { redirect } from "next/navigation";
+
+import {
+ AuthComponentFallback,
+ AuthPage,
+} from "@/components/layout/auth-page";
+import {
+ AuthState,
+ configurationStateKind,
+} from "@/components/layout/auth-state";
+import { getClerkSessionState } from "@/lib/auth-session.server";
+import {
+ APP_ROUTE,
+ SIGN_IN_ROUTE,
+ SIGN_UP_ROUTE,
+} from "@/lib/auth-routes";
+import { getClerkConfiguration } from "@/lib/clerk-config.server";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = {
+ title: "Create account",
+ description: "Create an account for the protected BridgeWorks application.",
+};
+
+export default async function SignUpPage() {
+ const configuration = getClerkConfiguration();
+
+ if (configuration.status !== "configured") {
+ return (
+
+
+
+ );
+ }
+
+ const session = await getClerkSessionState();
+ if (session.status === "signed-in") {
+ redirect(APP_ROUTE);
+ }
+ if (session.status === "session-unavailable") {
+ return (
+
+
+
+ );
+ }
+ if (session.status === "unexpected") {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ }
+ />
+
+ );
+}
diff --git a/src/app/(protected)/app/error.tsx b/src/app/(protected)/app/error.tsx
new file mode 100644
index 0000000..3f162c1
--- /dev/null
+++ b/src/app/(protected)/app/error.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { AlertTriangle } from "lucide-react";
+import Link from "next/link";
+
+import { Button } from "@/components/ui/button";
+import { HOME_ROUTE } from "@/lib/auth-routes";
+
+type AppErrorProps = {
+ error: Error & { digest?: string };
+ reset: () => void;
+};
+
+export default function AppError({ reset }: AppErrorProps) {
+ return (
+
+
+
+
+
+ The application could not finish loading
+
+
+ BridgeWorks stopped before rendering incomplete protected content. Try
+ the request again, or return to the public site.
+
+ );
+}
diff --git a/src/app/(protected)/app/page.tsx b/src/app/(protected)/app/page.tsx
new file mode 100644
index 0000000..8834934
--- /dev/null
+++ b/src/app/(protected)/app/page.tsx
@@ -0,0 +1,98 @@
+import { CheckCircle2 } from "lucide-react";
+import type { Metadata } from "next";
+
+import {
+ AuthState,
+ configurationStateKind,
+} from "@/components/layout/auth-state";
+import { getClerkSessionState } from "@/lib/auth-session.server";
+import { APP_ROUTE } from "@/lib/auth-routes";
+import { getClerkConfiguration } from "@/lib/clerk-config.server";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = {
+ title: "Overview",
+ description: "The protected BridgeWorks application foundation.",
+};
+
+const foundationCapabilities = [
+ "Protected server rendering with a fail-closed authentication boundary",
+ "Responsive navigation for mobile, tablet, and desktop workspaces",
+ "Accessible account controls ready for future product vertical slices",
+];
+
+export default async function AppOverviewPage() {
+ const configuration = getClerkConfiguration();
+ if (configuration.status !== "configured") {
+ return (
+
+ );
+ }
+
+ const session = await getClerkSessionState();
+ if (session.status === "signed-out") {
+ return session.redirectToSignIn({ returnBackUrl: APP_ROUTE });
+ }
+ if (session.status === "session-unavailable") {
+ return ;
+ }
+ if (session.status === "unexpected") {
+ return ;
+ }
+
+ return (
+
+
+
+ Authenticated workspace
+
+
+ Overview
+
+
+ The protected BridgeWorks application shell is ready. Product
+ workflows will be added as focused vertical slices without weakening
+ this authentication boundary.
+
+
+
+
+
+ Foundation ready
+
+
+ {foundationCapabilities.map((capability) => (
+
+
+ {capability}
+
+ ))}
+
+
+
+
+
+ What comes next
+
+
+ The next approved feature can attach to this shell after its backend
+ contract, authorization policy, states, and accessibility behavior are
+ verified.
+