From 3b13f8e0b10b43ef742099cd7804fd3f6155b5c2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:51:53 +0700
Subject: [PATCH 01/45] feat(auth): classify Clerk configuration states
---
src/lib/clerk-config.ts | 115 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 115 insertions(+)
create mode 100644 src/lib/clerk-config.ts
diff --git a/src/lib/clerk-config.ts b/src/lib/clerk-config.ts
new file mode 100644
index 0000000..e421393
--- /dev/null
+++ b/src/lib/clerk-config.ts
@@ -0,0 +1,115 @@
+export const CLERK_ENV_KEYS = {
+ publishableKey: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY",
+ secretKey: "CLERK_SECRET_KEY",
+} as const;
+
+type ClerkEnvironmentKey =
+ (typeof CLERK_ENV_KEYS)[keyof typeof CLERK_ENV_KEYS];
+
+type ClerkInstanceEnvironment = "test" | "live";
+
+export type ClerkConfiguration =
+ | {
+ status: "configured";
+ publishableKey: string;
+ environment: ClerkInstanceEnvironment;
+ }
+ | {
+ status: "missing";
+ keys: readonly ClerkEnvironmentKey[];
+ }
+ | {
+ status: "placeholder";
+ keys: readonly ClerkEnvironmentKey[];
+ }
+ | {
+ status: "malformed";
+ keys: readonly ClerkEnvironmentKey[];
+ reason: "invalid_format" | "environment_mismatch";
+ };
+
+type ClerkEnvironmentInput = {
+ publishableKey?: string;
+ secretKey?: string;
+};
+
+const publishableKeyPattern = /^pk_(test|live)_[A-Za-z0-9_-]{16,}$/;
+const secretKeyPattern = /^sk_(test|live)_[A-Za-z0-9_-]{16,}$/;
+
+function normalize(value: string | undefined) {
+ return value?.trim() ?? "";
+}
+
+function isPlaceholder(value: string) {
+ const normalized = value.toLowerCase();
+
+ return (
+ normalized.includes("replace_me") ||
+ normalized.includes("placeholder") ||
+ normalized.includes("your_key") ||
+ normalized.startsWith("<") ||
+ normalized.endsWith(">")
+ );
+}
+
+export function classifyClerkConfiguration(
+ input: ClerkEnvironmentInput,
+): ClerkConfiguration {
+ const publishableKey = normalize(input.publishableKey);
+ const secretKey = normalize(input.secretKey);
+
+ const missingKeys: ClerkEnvironmentKey[] = [];
+ if (!publishableKey) {
+ missingKeys.push(CLERK_ENV_KEYS.publishableKey);
+ }
+ if (!secretKey) {
+ missingKeys.push(CLERK_ENV_KEYS.secretKey);
+ }
+ if (missingKeys.length > 0) {
+ return { status: "missing", keys: missingKeys };
+ }
+
+ const placeholderKeys: ClerkEnvironmentKey[] = [];
+ if (isPlaceholder(publishableKey)) {
+ placeholderKeys.push(CLERK_ENV_KEYS.publishableKey);
+ }
+ if (isPlaceholder(secretKey)) {
+ placeholderKeys.push(CLERK_ENV_KEYS.secretKey);
+ }
+ if (placeholderKeys.length > 0) {
+ return { status: "placeholder", keys: placeholderKeys };
+ }
+
+ const publishableMatch = publishableKey.match(publishableKeyPattern);
+ const secretMatch = secretKey.match(secretKeyPattern);
+ const malformedKeys: ClerkEnvironmentKey[] = [];
+ if (!publishableMatch) {
+ malformedKeys.push(CLERK_ENV_KEYS.publishableKey);
+ }
+ if (!secretMatch) {
+ malformedKeys.push(CLERK_ENV_KEYS.secretKey);
+ }
+ if (malformedKeys.length > 0) {
+ return {
+ status: "malformed",
+ keys: malformedKeys,
+ reason: "invalid_format",
+ };
+ }
+
+ const publishableEnvironment = publishableMatch[1] as ClerkInstanceEnvironment;
+ const secretEnvironment = secretMatch[1] as ClerkInstanceEnvironment;
+ if (publishableEnvironment !== secretEnvironment) {
+ return {
+ status: "malformed",
+ keys: [CLERK_ENV_KEYS.publishableKey, CLERK_ENV_KEYS.secretKey],
+ reason: "environment_mismatch",
+ };
+ }
+
+ return {
+ status: "configured",
+ publishableKey,
+ environment: publishableEnvironment,
+ };
+}
From e5dd5ff18d90279fb4c95c692168cb126ec483d4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:52:08 +0700
Subject: [PATCH 02/45] feat(auth): read Clerk configuration centrally
---
src/lib/clerk-config.server.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
create mode 100644 src/lib/clerk-config.server.ts
diff --git a/src/lib/clerk-config.server.ts b/src/lib/clerk-config.server.ts
new file mode 100644
index 0000000..35d310f
--- /dev/null
+++ b/src/lib/clerk-config.server.ts
@@ -0,0 +1,15 @@
+import {
+ classifyClerkConfiguration,
+ type ClerkConfiguration,
+} from "@/lib/clerk-config";
+
+/**
+ * Server-only configuration boundary. The secret is inspected for validity and
+ * discarded; callers can never read or serialize it.
+ */
+export function getClerkConfiguration(): ClerkConfiguration {
+ return classifyClerkConfiguration({
+ publishableKey: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
+ secretKey: process.env.CLERK_SECRET_KEY,
+ });
+}
From b5234b11c3cbc5d38ef036a32856a0e00e48a8d4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:52:24 +0700
Subject: [PATCH 03/45] test(auth): cover Clerk configuration states
---
src/lib/clerk-config.test.ts | 89 ++++++++++++++++++++++++++++++++++++
1 file changed, 89 insertions(+)
create mode 100644 src/lib/clerk-config.test.ts
diff --git a/src/lib/clerk-config.test.ts b/src/lib/clerk-config.test.ts
new file mode 100644
index 0000000..37f5616
--- /dev/null
+++ b/src/lib/clerk-config.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+
+import { classifyClerkConfiguration } from "@/lib/clerk-config";
+
+const publishableTestKey = `pk_test_${"a".repeat(24)}`;
+const secretTestKey = `sk_test_${"b".repeat(24)}`;
+const publishableLiveKey = `pk_live_${"c".repeat(24)}`;
+
+ describe("classifyClerkConfiguration", () => {
+ it("classifies matching valid keys as configured without returning the secret", () => {
+ const result = classifyClerkConfiguration({
+ publishableKey: ` ${publishableTestKey} `,
+ secretKey: ` ${secretTestKey} `,
+ });
+
+ expect(result).toEqual({
+ status: "configured",
+ publishableKey: publishableTestKey,
+ environment: "test",
+ });
+ expect(result).not.toHaveProperty("secretKey");
+ });
+
+ it("reports every missing key", () => {
+ expect(classifyClerkConfiguration({})).toEqual({
+ status: "missing",
+ keys: [
+ "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY",
+ "CLERK_SECRET_KEY",
+ ],
+ });
+ });
+
+ it("treats blank values as missing before inspecting placeholders", () => {
+ expect(
+ classifyClerkConfiguration({
+ publishableKey: " ",
+ secretKey: "sk_test_replace_me",
+ }),
+ ).toEqual({
+ status: "missing",
+ keys: ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"],
+ });
+ });
+
+ it("classifies checked-in example values as placeholders", () => {
+ expect(
+ classifyClerkConfiguration({
+ publishableKey: "pk_test_replace_me",
+ secretKey: "sk_test_replace_me",
+ }),
+ ).toEqual({
+ status: "placeholder",
+ keys: [
+ "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY",
+ "CLERK_SECRET_KEY",
+ ],
+ });
+ });
+
+ it("classifies invalid key formats as malformed", () => {
+ expect(
+ classifyClerkConfiguration({
+ publishableKey: "publishable-value",
+ secretKey: secretTestKey,
+ }),
+ ).toEqual({
+ status: "malformed",
+ keys: ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"],
+ reason: "invalid_format",
+ });
+ });
+
+ it("rejects test and live keys from different Clerk instances", () => {
+ expect(
+ classifyClerkConfiguration({
+ publishableKey: publishableLiveKey,
+ secretKey: secretTestKey,
+ }),
+ ).toEqual({
+ status: "malformed",
+ keys: [
+ "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY",
+ "CLERK_SECRET_KEY",
+ ],
+ reason: "environment_mismatch",
+ });
+ });
+});
From eb7e0bd31b5df15e76e313f23999424c74ad81f0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:52:35 +0700
Subject: [PATCH 04/45] feat(auth): define explicit application routes
---
src/lib/auth-routes.ts | 14 ++++++++++++++
1 file changed, 14 insertions(+)
create mode 100644 src/lib/auth-routes.ts
diff --git a/src/lib/auth-routes.ts b/src/lib/auth-routes.ts
new file mode 100644
index 0000000..9ae1989
--- /dev/null
+++ b/src/lib/auth-routes.ts
@@ -0,0 +1,14 @@
+export const HOME_ROUTE = "/";
+export const APP_ROUTE = "/app";
+export const SIGN_IN_ROUTE = "/sign-in";
+export const SIGN_UP_ROUTE = "/sign-up";
+
+export function isProtectedAppPathname(pathname: string) {
+ const normalizedPathname =
+ pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
+
+ return (
+ normalizedPathname === APP_ROUTE ||
+ normalizedPathname.startsWith(`${APP_ROUTE}/`)
+ );
+}
From 2720dc6996b1737a11d3a154b6325a594fb800ea Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:52:44 +0700
Subject: [PATCH 05/45] test(auth): cover protected route matching
---
src/lib/auth-routes.test.ts | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
create mode 100644 src/lib/auth-routes.test.ts
diff --git a/src/lib/auth-routes.test.ts b/src/lib/auth-routes.test.ts
new file mode 100644
index 0000000..ed2c184
--- /dev/null
+++ b/src/lib/auth-routes.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+
+import { isProtectedAppPathname } from "@/lib/auth-routes";
+
+describe("isProtectedAppPathname", () => {
+ it.each(["/app", "/app/", "/app/settings", "/app/settings/profile/"])(
+ "protects %s",
+ (pathname) => {
+ expect(isProtectedAppPathname(pathname)).toBe(true);
+ },
+ );
+
+ it.each([
+ "/",
+ "/sign-in",
+ "/sign-up",
+ "/application",
+ "/app-store",
+ "/api/app",
+ "/api/v1/me",
+ ])("leaves %s outside the application policy", (pathname) => {
+ expect(isProtectedAppPathname(pathname)).toBe(false);
+ });
+});
From 312df03f6a45b3f7918ceaeb2627f1fe57a52a31 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:52:58 +0700
Subject: [PATCH 06/45] feat(auth): centralize server session state
---
src/lib/auth-session.server.ts | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
create mode 100644 src/lib/auth-session.server.ts
diff --git a/src/lib/auth-session.server.ts b/src/lib/auth-session.server.ts
new file mode 100644
index 0000000..9d91ac7
--- /dev/null
+++ b/src/lib/auth-session.server.ts
@@ -0,0 +1,25 @@
+import { auth } from "@clerk/nextjs/server";
+
+export async function getClerkSessionState() {
+ try {
+ const session = await auth();
+
+ if (!session.isAuthenticated) {
+ return {
+ status: "signed-out" as const,
+ redirectToSignIn: session.redirectToSignIn,
+ };
+ }
+
+ if (!session.sessionId) {
+ return { status: "session-unavailable" as const };
+ }
+
+ return {
+ status: "signed-in" as const,
+ sessionId: session.sessionId,
+ };
+ } catch {
+ return { status: "unexpected" as const };
+ }
+}
From 9c7f5ccb638fdf2d32c5acb7b81da56cc19743bc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:53:24 +0700
Subject: [PATCH 07/45] feat(auth): fail closed for protected application
routes
---
src/proxy.ts | 90 +++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 79 insertions(+), 11 deletions(-)
diff --git a/src/proxy.ts b/src/proxy.ts
index cf4d593..20a48f2 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -1,23 +1,91 @@
import { clerkMiddleware } from "@clerk/nextjs/server";
-import { NextResponse } from "next/server";
+import { NextResponse, type NextRequest } from "next/server";
-const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
-const secretKey = process.env.CLERK_SECRET_KEY;
+import {
+ SIGN_IN_ROUTE,
+ SIGN_UP_ROUTE,
+ isProtectedAppPathname,
+} from "@/lib/auth-routes";
+import { getClerkConfiguration } from "@/lib/clerk-config.server";
-const clerkConfigured = Boolean(
- publishableKey &&
- secretKey &&
- !publishableKey.includes("replace_me") &&
- !secretKey.includes("replace_me"),
-);
+const clerkConfiguration = getClerkConfiguration();
-const passThrough = () => NextResponse.next();
+function clerkUnavailableResponse() {
+ return new NextResponse(
+ `
+
+
+
+
+
+ Authentication unavailable | BridgeWorks
+
+
+
+
+ BridgeWorks
+ Authentication is temporarily unavailable
+ The protected application cannot open until authentication is configured correctly. Return to the public site or contact the application operator.
+ Return to BridgeWorks
+
+
+`,
+ {
+ status: 503,
+ headers: {
+ "Cache-Control": "no-store",
+ "Content-Security-Policy":
+ "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",
+ "Content-Type": "text/html; charset=utf-8",
+ "X-Robots-Tag": "noindex",
+ },
+ },
+ );
+}
-export default clerkConfigured ? clerkMiddleware() : passThrough;
+function unavailableProxy(request: NextRequest) {
+ if (isProtectedAppPathname(request.nextUrl.pathname)) {
+ return clerkUnavailableResponse();
+ }
+
+ return NextResponse.next();
+}
+
+const configuredProxy =
+ clerkConfiguration.status === "configured"
+ ? clerkMiddleware(
+ async (auth, request) => {
+ if (!isProtectedAppPathname(request.nextUrl.pathname)) {
+ return NextResponse.next();
+ }
+
+ const session = await auth();
+ if (!session.isAuthenticated) {
+ return session.redirectToSignIn({ returnBackUrl: request.url });
+ }
+
+ return NextResponse.next();
+ },
+ {
+ signInUrl: SIGN_IN_ROUTE,
+ signUpUrl: SIGN_UP_ROUTE,
+ },
+ )
+ : unavailableProxy;
+
+export default configuredProxy;
export const config = {
matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
+ "/__clerk/(.*)",
],
};
From 2417d1b2bd879f8d36258ff345282cec695be00b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:53:40 +0700
Subject: [PATCH 08/45] refactor(auth): use centralized Clerk provider
configuration
---
src/app/layout.tsx | 36 ++++++++++++++++++++++--------------
1 file changed, 22 insertions(+), 14 deletions(-)
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index fe96084..65dc169 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,13 @@ import { ClerkProvider } from "@clerk/nextjs";
import type { Metadata } from "next";
import { Geist_Mono, Inter } from "next/font/google";
+import {
+ APP_ROUTE,
+ HOME_ROUTE,
+ SIGN_IN_ROUTE,
+ SIGN_UP_ROUTE,
+} from "@/lib/auth-routes";
+import { getClerkConfiguration } from "@/lib/clerk-config.server";
import { cn } from "@/lib/utils";
import "./globals.css";
@@ -30,6 +37,7 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
+ const clerkConfiguration = getClerkConfiguration();
const document = (
);
- const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
- const secretKey = process.env.CLERK_SECRET_KEY;
- const clerkConfigured = Boolean(
- publishableKey &&
- secretKey &&
- !publishableKey.includes("replace_me") &&
- !secretKey.includes("replace_me"),
- );
-
- if (clerkConfigured && publishableKey) {
- return (
- {document}
- );
+ if (clerkConfiguration.status !== "configured") {
+ return document;
}
- return document;
+ return (
+
+ {document}
+
+ );
}
From 1878a371db0cda7f211d81314e2fa7f95b2c83b2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:54:04 +0700
Subject: [PATCH 09/45] feat(auth): add safe authentication state presentation
---
src/components/layout/auth-state.tsx | 95 ++++++++++++++++++++++++++++
1 file changed, 95 insertions(+)
create mode 100644 src/components/layout/auth-state.tsx
diff --git a/src/components/layout/auth-state.tsx b/src/components/layout/auth-state.tsx
new file mode 100644
index 0000000..b828cc9
--- /dev/null
+++ b/src/components/layout/auth-state.tsx
@@ -0,0 +1,95 @@
+import { AlertTriangle } from "lucide-react";
+import Link from "next/link";
+
+import { APP_ROUTE, HOME_ROUTE } from "@/lib/auth-routes";
+import type { ClerkConfiguration } from "@/lib/clerk-config";
+
+type AuthStateKind =
+ | "configuration-missing"
+ | "configuration-placeholder"
+ | "configuration-malformed"
+ | "session-unavailable"
+ | "unexpected";
+
+type AuthStateProps = {
+ kind: AuthStateKind;
+ headingLevel?: "h1" | "h2";
+};
+
+const stateContent: Record<
+ AuthStateKind,
+ { title: string; description: string; actionLabel: string; actionHref: string }
+> = {
+ "configuration-missing": {
+ title: "Authentication is not configured",
+ description:
+ "BridgeWorks public pages remain available, but secure account access requires the application operator to finish the Clerk configuration.",
+ actionLabel: "Return to BridgeWorks",
+ actionHref: HOME_ROUTE,
+ },
+ "configuration-placeholder": {
+ title: "Authentication setup is incomplete",
+ description:
+ "Example credentials are still active. Replace them with a valid Clerk development or production key pair before opening secure routes.",
+ actionLabel: "Return to BridgeWorks",
+ actionHref: HOME_ROUTE,
+ },
+ "configuration-malformed": {
+ title: "Authentication configuration is invalid",
+ description:
+ "The configured Clerk key pair cannot be used safely. The application operator must correct the configuration before secure routes can open.",
+ actionLabel: "Return to BridgeWorks",
+ actionHref: HOME_ROUTE,
+ },
+ "session-unavailable": {
+ title: "Your session is not ready",
+ description:
+ "BridgeWorks confirmed an account but could not establish a usable session. Reload the protected application to try again.",
+ actionLabel: "Reload the application",
+ actionHref: APP_ROUTE,
+ },
+ unexpected: {
+ title: "Authentication could not be verified",
+ description:
+ "A temporary authentication error prevented BridgeWorks from confirming the session. No protected content was rendered.",
+ actionLabel: "Try the application again",
+ actionHref: APP_ROUTE,
+ },
+};
+
+export function configurationStateKind(
+ configuration: Exclude,
+): AuthStateKind {
+ return `configuration-${configuration.status}`;
+}
+
+export function AuthState({ kind, headingLevel = "h2" }: AuthStateProps) {
+ const content = stateContent[kind];
+ const Heading = headingLevel;
+
+ return (
+
+
+
+ {content.title}
+
+
+ {content.description}
+
+
+ {content.actionLabel}
+
+
+ );
+}
From f46bd97b016cb3675bebf0c616b6e37cc82ad3f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:54:23 +0700
Subject: [PATCH 10/45] feat(auth): add responsive authentication page layout
---
src/components/layout/auth-page.tsx | 54 +++++++++++++++++++++++++++++
1 file changed, 54 insertions(+)
create mode 100644 src/components/layout/auth-page.tsx
diff --git a/src/components/layout/auth-page.tsx b/src/components/layout/auth-page.tsx
new file mode 100644
index 0000000..d2c4abd
--- /dev/null
+++ b/src/components/layout/auth-page.tsx
@@ -0,0 +1,54 @@
+import Link from "next/link";
+
+import { HOME_ROUTE } from "@/lib/auth-routes";
+
+type AuthPageProps = {
+ title: string;
+ description: string;
+ children: React.ReactNode;
+};
+
+export function AuthPage({ title, description, children }: AuthPageProps) {
+ return (
+
+
+
+ );
+}
+
+export function AuthComponentFallback() {
+ return (
+
+ Loading authentication form
+
+ );
+}
From 60003390b26ccc854b9a0f86b13e5b8c4833c9e6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:54:43 +0700
Subject: [PATCH 11/45] feat(shell): add keyboard accessible mobile navigation
---
.../layout/mobile-app-navigation.tsx | 90 +++++++++++++++++++
1 file changed, 90 insertions(+)
create mode 100644 src/components/layout/mobile-app-navigation.tsx
diff --git a/src/components/layout/mobile-app-navigation.tsx b/src/components/layout/mobile-app-navigation.tsx
new file mode 100644
index 0000000..b4de3ed
--- /dev/null
+++ b/src/components/layout/mobile-app-navigation.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { Home, Menu, X } from "lucide-react";
+import Link from "next/link";
+
+import { Button } from "@/components/ui/button";
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import { APP_ROUTE } from "@/lib/auth-routes";
+
+type MobileAppNavigationProps = {
+ accountControl: React.ReactNode;
+};
+
+export function MobileAppNavigation({
+ accountControl,
+}: MobileAppNavigationProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ BridgeWorks
+
+
+ Application navigation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Overview
+
+
+
+
+
+
+ Account
+
+ {accountControl}
+
+
+
+ );
+}
From efc163b7696ebbf8d44df854937f7ce8e67f001e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:55:08 +0700
Subject: [PATCH 12/45] feat(shell): add responsive authenticated application
shell
---
src/components/layout/app-shell.tsx | 75 +++++++++++++++++++++++++++++
1 file changed, 75 insertions(+)
create mode 100644 src/components/layout/app-shell.tsx
diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx
new file mode 100644
index 0000000..9a4297c
--- /dev/null
+++ b/src/components/layout/app-shell.tsx
@@ -0,0 +1,75 @@
+import { Home } from "lucide-react";
+import Link from "next/link";
+
+import { MobileAppNavigation } from "@/components/layout/mobile-app-navigation";
+import { APP_ROUTE } from "@/lib/auth-routes";
+
+type AppShellProps = {
+ accountControl: React.ReactNode;
+ children: React.ReactNode;
+};
+
+function BrandLink() {
+ return (
+
+ BridgeWorks
+
+ );
+}
+
+export function AppShell({ accountControl, children }: AppShellProps) {
+ return (
+
+
+ Skip to main content
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Overview
+
+
+
+
+
+ Account
+
+
+ {accountControl}
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
From 912c379af0d61862b63148bfc244f7d5aa2c73b0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:55:31 +0700
Subject: [PATCH 13/45] feat(auth): add Clerk sign-in route
---
.../(auth)/sign-in/[[...sign-in]]/page.tsx | 80 +++++++++++++++++++
1 file changed, 80 insertions(+)
create mode 100644 src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
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..86eecc7
--- /dev/null
+++ b/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
@@ -0,0 +1,80 @@
+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 {
+ APP_ROUTE,
+ SIGN_IN_ROUTE,
+ SIGN_UP_ROUTE,
+} from "@/lib/auth-routes";
+import { getClerkConfiguration } from "@/lib/clerk-config.server";
+import { getClerkSessionState } from "@/lib/auth-session.server";
+
+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 (
+
+ }
+ />
+
+ );
+}
From a34e6a0cfdbb162a7e5c0263fadaf210a4af7e77 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:55:46 +0700
Subject: [PATCH 14/45] feat(auth): add Clerk sign-up route
---
.../(auth)/sign-up/[[...sign-up]]/page.tsx | 80 +++++++++++++++++++
1 file changed, 80 insertions(+)
create mode 100644 src/app/(auth)/sign-up/[[...sign-up]]/page.tsx
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..bc3fe19
--- /dev/null
+++ b/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx
@@ -0,0 +1,80 @@
+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 {
+ APP_ROUTE,
+ SIGN_IN_ROUTE,
+ SIGN_UP_ROUTE,
+} from "@/lib/auth-routes";
+import { getClerkConfiguration } from "@/lib/clerk-config.server";
+import { getClerkSessionState } from "@/lib/auth-session.server";
+
+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 (
+
+ }
+ />
+
+ );
+}
From a4e61eafb5f540f83e3a4d8de7f664da311bae5c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:56:10 +0700
Subject: [PATCH 15/45] feat(shell): protect and render authenticated
application layout
---
src/app/(protected)/app/layout.tsx | 75 ++++++++++++++++++++++++++++++
1 file changed, 75 insertions(+)
create mode 100644 src/app/(protected)/app/layout.tsx
diff --git a/src/app/(protected)/app/layout.tsx b/src/app/(protected)/app/layout.tsx
new file mode 100644
index 0000000..5269171
--- /dev/null
+++ b/src/app/(protected)/app/layout.tsx
@@ -0,0 +1,75 @@
+import { UserButton } from "@clerk/nextjs";
+
+import { AppShell } from "@/components/layout/app-shell";
+import {
+ AuthState,
+ configurationStateKind,
+} from "@/components/layout/auth-state";
+import { APP_ROUTE } from "@/lib/auth-routes";
+import { getClerkConfiguration } from "@/lib/clerk-config.server";
+import { getClerkSessionState } from "@/lib/auth-session.server";
+
+function StandaloneAuthState({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ return (
+
+ {children}
+
+ );
+}
+
+function AccountControlFallback() {
+ return (
+
+ Loading account controls
+
+ );
+}
+
+export default async function ProtectedAppLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ 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 (
+
+
+
+ );
+ }
+
+ const accountControl = (
+ } />
+ );
+
+ return {children} ;
+}
From 6d22fdfebd3cc3d39b76e52ae56c4906e4c295f5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:56:31 +0700
Subject: [PATCH 16/45] feat(shell): add authenticated overview foundation page
---
src/app/(protected)/app/page.tsx | 96 ++++++++++++++++++++++++++++++++
1 file changed, 96 insertions(+)
create mode 100644 src/app/(protected)/app/page.tsx
diff --git a/src/app/(protected)/app/page.tsx b/src/app/(protected)/app/page.tsx
new file mode 100644
index 0000000..5d70550
--- /dev/null
+++ b/src/app/(protected)/app/page.tsx
@@ -0,0 +1,96 @@
+import { CheckCircle2 } from "lucide-react";
+import type { Metadata } from "next";
+
+import {
+ AuthState,
+ configurationStateKind,
+} from "@/components/layout/auth-state";
+import { APP_ROUTE } from "@/lib/auth-routes";
+import { getClerkConfiguration } from "@/lib/clerk-config.server";
+import { getClerkSessionState } from "@/lib/auth-session.server";
+
+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.
+
+
+
+ );
+}
From f670932d9c0749ff2f7c71590d66be4c3b4ee1a5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:56:44 +0700
Subject: [PATCH 17/45] feat(shell): add stable application loading state
---
src/app/(protected)/app/loading.tsx | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
create mode 100644 src/app/(protected)/app/loading.tsx
diff --git a/src/app/(protected)/app/loading.tsx b/src/app/(protected)/app/loading.tsx
new file mode 100644
index 0000000..1d47235
--- /dev/null
+++ b/src/app/(protected)/app/loading.tsx
@@ -0,0 +1,17 @@
+export default function AppLoading() {
+ return (
+
+
+
+
Loading application overview
+
+ );
+}
From c95daa339212d211972d6adc4f4b888eef034a1a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:56:57 +0700
Subject: [PATCH 18/45] feat(shell): add redacted application error recovery
---
src/app/(protected)/app/error.tsx | 38 +++++++++++++++++++++++++++++++
1 file changed, 38 insertions(+)
create mode 100644 src/app/(protected)/app/error.tsx
diff --git a/src/app/(protected)/app/error.tsx b/src/app/(protected)/app/error.tsx
new file mode 100644
index 0000000..77c80ea
--- /dev/null
+++ b/src/app/(protected)/app/error.tsx
@@ -0,0 +1,38 @@
+"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";
+
+export default function AppError({ reset }: { reset: () => void }) {
+ return (
+
+
+
+ The application could not finish loading
+
+
+ BridgeWorks stopped before rendering incomplete protected content. Try
+ the request again, or return to the public site.
+
+
+
+ Try again
+
+
+ Return to BridgeWorks
+
+
+
+ );
+}
From 32adca4705d03d82eed6801e5e634f5be82947e3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:57:24 +0700
Subject: [PATCH 19/45] style(a11y): respect reduced motion globally
---
src/app/globals.css | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/src/app/globals.css b/src/app/globals.css
index c56032b..17fe016 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -127,4 +127,15 @@
html {
@apply font-sans;
}
-}
\ No newline at end of file
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
From 5bc63f70ece8f60355e97ec337626ea33c6df922 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:57:49 +0700
Subject: [PATCH 20/45] test(shell): cover rendering and keyboard navigation
---
src/components/layout/app-shell.test.tsx | 70 ++++++++++++++++++++++++
1 file changed, 70 insertions(+)
create mode 100644 src/components/layout/app-shell.test.tsx
diff --git a/src/components/layout/app-shell.test.tsx b/src/components/layout/app-shell.test.tsx
new file mode 100644
index 0000000..9034d1d
--- /dev/null
+++ b/src/components/layout/app-shell.test.tsx
@@ -0,0 +1,70 @@
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+
+import { AppShell } from "@/components/layout/app-shell";
+
+function renderShell(accountName = "Taylor Bridge") {
+ return render(
+ Account for {accountName}}
+ >
+ Overview
+ Protected application content
+ ,
+ );
+}
+
+describe("AppShell", () => {
+ it("renders semantic navigation, a skip link, and the main landmark", () => {
+ renderShell();
+
+ expect(
+ screen.getByRole("link", { name: "Skip to main content" }),
+ ).toHaveAttribute("href", "#main-content");
+ expect(screen.getByRole("navigation", { name: "Application" })).toBeVisible();
+ expect(
+ within(screen.getByRole("navigation", { name: "Application" })).getByRole(
+ "link",
+ { name: "Overview" },
+ ),
+ ).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("main")).toHaveAttribute("id", "main-content");
+ expect(screen.getByRole("heading", { name: "Overview" })).toBeVisible();
+ });
+
+ it("opens and closes mobile navigation with keyboard focus returned", async () => {
+ const user = userEvent.setup();
+ renderShell();
+
+ const trigger = screen.getByRole("button", { name: "Open navigation" });
+ trigger.focus();
+ await user.keyboard("{Enter}");
+
+ const dialog = await screen.findByRole("dialog");
+ expect(
+ within(dialog).getByRole("navigation", {
+ name: "Mobile application",
+ }),
+ ).toBeVisible();
+ expect(
+ within(dialog).getByRole("button", { name: "Close navigation" }),
+ ).toBeVisible();
+
+ await user.keyboard("{Escape}");
+ await waitFor(() => expect(trigger).toHaveFocus());
+ });
+
+ it("keeps the shell usable with a long account display name", () => {
+ renderShell(
+ "Alexandria Montgomery-Wellington the Third from BridgeWorks Operations",
+ );
+
+ expect(screen.getByRole("main")).toBeVisible();
+ expect(
+ screen.getByRole("button", {
+ name: /Alexandria Montgomery-Wellington/,
+ }),
+ ).toBeInTheDocument();
+ });
+});
From ddbfdf79f103b0912776f5c76a66b8439edcd68e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:58:12 +0700
Subject: [PATCH 21/45] test(storybook): add authenticated shell stories
---
src/components/layout/app-shell.stories.tsx | 89 +++++++++++++++++++++
1 file changed, 89 insertions(+)
create mode 100644 src/components/layout/app-shell.stories.tsx
diff --git a/src/components/layout/app-shell.stories.tsx b/src/components/layout/app-shell.stories.tsx
new file mode 100644
index 0000000..58c3992
--- /dev/null
+++ b/src/components/layout/app-shell.stories.tsx
@@ -0,0 +1,89 @@
+import type { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { expect, userEvent, within } from "storybook/test";
+
+import { AppShell } from "@/components/layout/app-shell";
+
+function OverviewContent() {
+ return (
+
+
+
+ Authenticated workspace
+
+ Overview
+
+ The protected BridgeWorks application shell is ready for focused
+ product vertical slices.
+
+
+
+ Foundation ready
+
+ This story uses presentation-only account data and does not require a
+ Clerk secret.
+
+
+
+ );
+}
+
+const meta = {
+ title: "Layout/App Shell",
+ component: AppShell,
+ parameters: {
+ layout: "fullscreen",
+ controls: { disable: true },
+ },
+ args: {
+ accountControl: Account for Taylor Bridge ,
+ children: ,
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Desktop: Story = {
+ parameters: {
+ viewport: { defaultViewport: "desktop" },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByRole("main")).toBeVisible();
+ await expect(
+ canvas.getByRole("navigation", { name: "Application" }),
+ ).toBeVisible();
+ },
+};
+
+export const MobileNavigation: Story = {
+ parameters: {
+ viewport: { defaultViewport: "mobile1" },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole("button", { name: "Open navigation" });
+
+ await userEvent.click(trigger);
+
+ const page = within(canvasElement.ownerDocument.body);
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await expect(
+ page.getByRole("navigation", { name: "Mobile application" }),
+ ).toBeVisible();
+
+ await userEvent.keyboard("{Escape}");
+ await expect(trigger).toHaveFocus();
+ },
+};
+
+export const LongDisplayName: Story = {
+ args: {
+ accountControl: (
+
+ Account for Alexandria Montgomery-Wellington from BridgeWorks
+ Operations
+
+ ),
+ },
+};
From 7b1d51f1d265406a533d690d3c6e80e81b0df70e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:58:21 +0700
Subject: [PATCH 22/45] test(storybook): add authentication unavailable stories
---
src/components/layout/auth-state.stories.tsx | 37 ++++++++++++++++++++
1 file changed, 37 insertions(+)
create mode 100644 src/components/layout/auth-state.stories.tsx
diff --git a/src/components/layout/auth-state.stories.tsx b/src/components/layout/auth-state.stories.tsx
new file mode 100644
index 0000000..625c413
--- /dev/null
+++ b/src/components/layout/auth-state.stories.tsx
@@ -0,0 +1,37 @@
+import type { Meta, StoryObj } from "@storybook/nextjs-vite";
+
+import { AuthState } from "@/components/layout/auth-state";
+
+const meta = {
+ title: "Layout/Authentication States",
+ component: AuthState,
+ parameters: {
+ layout: "centered",
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const MissingConfiguration: Story = {
+ args: { kind: "configuration-missing", headingLevel: "h1" },
+};
+
+export const PlaceholderConfiguration: Story = {
+ args: { kind: "configuration-placeholder", headingLevel: "h1" },
+};
+
+export const MalformedConfiguration: Story = {
+ args: { kind: "configuration-malformed", headingLevel: "h1" },
+};
+
+export const SessionUnavailable: Story = {
+ args: { kind: "session-unavailable", headingLevel: "h1" },
+};
From ce07aa7baf3f916aaa80ca71ac9b70033ccf0ee1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:58:39 +0700
Subject: [PATCH 23/45] test: configure DOM component test environment
---
vitest.setup.ts | 33 +++++++++++++++++++++++++++++++++
1 file changed, 33 insertions(+)
create mode 100644 vitest.setup.ts
diff --git a/vitest.setup.ts b/vitest.setup.ts
new file mode 100644
index 0000000..8b4cea8
--- /dev/null
+++ b/vitest.setup.ts
@@ -0,0 +1,33 @@
+import "@testing-library/jest-dom/vitest";
+
+import { cleanup } from "@testing-library/react";
+import { afterEach, vi } from "vitest";
+
+afterEach(() => {
+ cleanup();
+});
+
+Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+class ResizeObserverMock {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+Object.defineProperty(globalThis, "ResizeObserver", {
+ writable: true,
+ value: ResizeObserverMock,
+});
From 9386d291df1fe853c5dcad6012411dbc5b56d681 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:58:54 +0700
Subject: [PATCH 24/45] test: add unit and Storybook Vitest projects
---
vitest.config.ts | 39 +++++++++++++++++++++++++--------------
1 file changed, 25 insertions(+), 14 deletions(-)
diff --git a/vitest.config.ts b/vitest.config.ts
index 9e4b59b..c1ac732 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,33 +1,44 @@
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
+import path from "node:path";
+import { fileURLToPath } from "node:url";
-import { defineConfig } from 'vitest/config';
-
-import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
-
-import { playwright } from '@vitest/browser-playwright';
+import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
+import { playwright } from "@vitest/browser-playwright";
+import { defineConfig } from "vitest/config";
const dirname =
- typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
+ typeof __dirname !== "undefined"
+ ? __dirname
+ : path.dirname(fileURLToPath(import.meta.url));
-// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({
+ resolve: {
+ alias: {
+ "@": path.resolve(dirname, "src"),
+ },
+ },
test: {
projects: [
+ {
+ extends: true,
+ test: {
+ name: "unit",
+ environment: "jsdom",
+ include: ["src/**/*.test.{ts,tsx}"],
+ setupFiles: [path.join(dirname, "vitest.setup.ts")],
+ },
+ },
{
extends: true,
plugins: [
- // The plugin will run tests for the stories defined in your Storybook config
- // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
- storybookTest({ configDir: path.join(dirname, '.storybook') }),
+ storybookTest({ configDir: path.join(dirname, ".storybook") }),
],
test: {
- name: 'storybook',
+ name: "storybook",
browser: {
enabled: true,
headless: true,
provider: playwright({}),
- instances: [{ browser: 'chromium' }],
+ instances: [{ browser: "chromium" }],
},
},
},
From 7eba6169bc0a5e14aef8d791c39c53125f1b7e39 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:59:18 +0700
Subject: [PATCH 25/45] test: run the dedicated unit project
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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",
From a795e102589c02f44fef9d6cb8c70ccf13b1c1a6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 00:59:42 +0700
Subject: [PATCH 26/45] test(e2e): cover public and fail-closed authentication
states
---
tests/example.spec.ts | 55 ++++++++++++++++++++++++++++++++++++++-----
1 file changed, 49 insertions(+), 6 deletions(-)
diff --git a/tests/example.spec.ts b/tests/example.spec.ts
index 57ff993..08f9804 100644
--- a/tests/example.spec.ts
+++ b/tests/example.spec.ts
@@ -1,7 +1,7 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
-test("renders the BridgeWorks frontend foundation", async ({ page }) => {
+test("renders the BridgeWorks public foundation", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/BridgeWorks/);
@@ -10,17 +10,60 @@ test("renders the BridgeWorks frontend foundation", async ({ page }) => {
name: "Build trusted work relationships, step by step.",
}),
).toBeVisible();
+});
+
+test("renders deterministic secretless sign-in and sign-up states", async ({
+ page,
+}) => {
+ await page.goto("/sign-in");
+ await expect(
+ page.getByRole("heading", { name: "Sign in to BridgeWorks", level: 1 }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", {
+ name: /Authentication (is not configured|setup is incomplete|configuration is invalid)/,
+ level: 2,
+ }),
+ ).toBeVisible();
+
+ await page.goto("/sign-up");
await expect(
- page.getByRole("link", { name: "Explore the product foundation" }),
+ page.getByRole("heading", {
+ name: "Create your BridgeWorks account",
+ level: 1,
+ }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", {
+ name: /Authentication (is not configured|setup is incomplete|configuration is invalid)/,
+ level: 2,
+ }),
).toBeVisible();
});
-test("has no automatically detectable accessibility violations", async ({
+test("fails closed when a protected route has no usable Clerk configuration", async ({
page,
}) => {
- await page.goto("/");
+ const response = await page.goto("/app");
- const results = await new AxeBuilder({ page }).analyze();
+ expect(response?.status()).toBe(503);
+ await expect(
+ page.getByRole("heading", {
+ name: "Authentication is temporarily unavailable",
+ level: 1,
+ }),
+ ).toBeVisible();
+ await expect(page.getByText(/sk_test|CLERK_SECRET_KEY/)).toHaveCount(0);
+});
- expect(results.violations).toEqual([]);
+test("has no automatically detectable accessibility violations", async ({
+ page,
+}) => {
+ for (const route of ["/", "/sign-in", "/sign-up", "/app"]) {
+ await page.goto(route);
+ const results = await new AxeBuilder({ page }).analyze();
+ expect(results.violations, `accessibility violations on ${route}`).toEqual(
+ [],
+ );
+ }
});
From 26a82e92d8c8599cdbe213acfb0ef997d2267df8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 01:00:05 +0700
Subject: [PATCH 27/45] feat(auth): link the public foundation to sign-in
---
src/app/page.tsx | 26 ++++++++++++++++----------
1 file changed, 16 insertions(+), 10 deletions(-)
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 36d051f..84249de 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -2,8 +2,8 @@ import Link from "next/link";
const foundations = [
"Next.js App Router with strict TypeScript",
- "Clerk-ready authentication boundary",
- "Storybook, Vitest, Playwright, and axe",
+ "Fail-closed Clerk authentication boundary",
+ "Secretless Storybook, Vitest, Playwright, and axe coverage",
];
export default function Home() {
@@ -12,9 +12,12 @@ export default function Home() {
@@ -32,15 +35,18 @@ export default function Home() {
work.
+
+ Sign in to BridgeWorks
+
- Explore the product foundation
+ Review the foundation
-
- Product workflows arrive in focused vertical slices.
-
From 3f58eeb00adccca444502a0b45fb3846b898c8e1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 01:00:38 +0700
Subject: [PATCH 28/45] docs(auth): document protected application shell
---
README.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 66 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index 7fd38c9..7465a96 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,56 @@ 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 have valid 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.
+
+`src/lib/clerk-config.server.ts` is the only environment-reading boundary. It validates the secret but never returns, logs, renders, or serializes it. 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 +101,28 @@ 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.
## Agent instructions and UI skills
@@ -82,7 +137,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 +150,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.
From 44e9f90657706b2a43247432d8fc6a5c305364eb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 01:02:10 +0700
Subject: [PATCH 29/45] ci: name expanded browser validation accurately
---
.github/workflows/playwright.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
From 45a15ebf793653b115c99ad16a5fdcf434bfcd4f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 01:05:36 +0700
Subject: [PATCH 30/45] fix(auth): narrow invalid Clerk key matches
---
src/lib/clerk-config.ts | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/src/lib/clerk-config.ts b/src/lib/clerk-config.ts
index e421393..ab4df3e 100644
--- a/src/lib/clerk-config.ts
+++ b/src/lib/clerk-config.ts
@@ -82,14 +82,15 @@ export function classifyClerkConfiguration(
const publishableMatch = publishableKey.match(publishableKeyPattern);
const secretMatch = secretKey.match(secretKeyPattern);
- const malformedKeys: ClerkEnvironmentKey[] = [];
- if (!publishableMatch) {
- malformedKeys.push(CLERK_ENV_KEYS.publishableKey);
- }
- if (!secretMatch) {
- malformedKeys.push(CLERK_ENV_KEYS.secretKey);
- }
- if (malformedKeys.length > 0) {
+ if (!publishableMatch || !secretMatch) {
+ const malformedKeys: ClerkEnvironmentKey[] = [];
+ if (!publishableMatch) {
+ malformedKeys.push(CLERK_ENV_KEYS.publishableKey);
+ }
+ if (!secretMatch) {
+ malformedKeys.push(CLERK_ENV_KEYS.secretKey);
+ }
+
return {
status: "malformed",
keys: malformedKeys,
From 44b837759bbad1704a931c73952b286dbdfde2a8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 01:06:21 +0700
Subject: [PATCH 31/45] fix(auth): render sign-in per request
---
src/app/(auth)/sign-in/[[...sign-in]]/page.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
index 86eecc7..d627ca4 100644
--- a/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
+++ b/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
@@ -10,13 +10,15 @@ 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";
-import { getClerkSessionState } from "@/lib/auth-session.server";
+
+export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Sign in",
From 6d6868daf43d3a136c48d4ce8717eea4eb7dedd3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 01:06:38 +0700
Subject: [PATCH 32/45] fix(auth): render sign-up per request
---
src/app/(auth)/sign-up/[[...sign-up]]/page.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx
index bc3fe19..9e60d92 100644
--- a/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx
+++ b/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx
@@ -10,13 +10,15 @@ 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";
-import { getClerkSessionState } from "@/lib/auth-session.server";
+
+export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Create account",
From 83b9ba92397aef2e33a814d3b2838dcf1e6aca22 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 01:06:53 +0700
Subject: [PATCH 33/45] fix(auth): render protected shell per request
---
src/app/(protected)/app/layout.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/app/(protected)/app/layout.tsx b/src/app/(protected)/app/layout.tsx
index 5269171..da544fd 100644
--- a/src/app/(protected)/app/layout.tsx
+++ b/src/app/(protected)/app/layout.tsx
@@ -5,9 +5,11 @@ 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";
-import { getClerkSessionState } from "@/lib/auth-session.server";
+
+export const dynamic = "force-dynamic";
function StandaloneAuthState({
children,
From b3dc28179e26187cfd90f0dca57d5d3861f66e60 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 01:07:15 +0700
Subject: [PATCH 34/45] fix(auth): verify protected page per request
---
src/app/(protected)/app/page.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/app/(protected)/app/page.tsx b/src/app/(protected)/app/page.tsx
index 5d70550..8834934 100644
--- a/src/app/(protected)/app/page.tsx
+++ b/src/app/(protected)/app/page.tsx
@@ -5,9 +5,11 @@ 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";
-import { getClerkSessionState } from "@/lib/auth-session.server";
+
+export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Overview",
From 0834d3501a6f43fda409126d8db23691fbb69470 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 01:07:33 +0700
Subject: [PATCH 35/45] fix(shell): use the Next.js error boundary contract
---
src/app/(protected)/app/error.tsx | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/app/(protected)/app/error.tsx b/src/app/(protected)/app/error.tsx
index 77c80ea..3f162c1 100644
--- a/src/app/(protected)/app/error.tsx
+++ b/src/app/(protected)/app/error.tsx
@@ -6,7 +6,12 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { HOME_ROUTE } from "@/lib/auth-routes";
-export default function AppError({ reset }: { reset: () => void }) {
+type AppErrorProps = {
+ error: Error & { digest?: string };
+ reset: () => void;
+};
+
+export default function AppError({ reset }: AppErrorProps) {
return (
Date: Wed, 5 Aug 2026 01:10:39 +0700
Subject: [PATCH 36/45] fix(storybook): wait for mobile sheet transitions
---
src/components/layout/app-shell.stories.tsx | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/src/components/layout/app-shell.stories.tsx b/src/components/layout/app-shell.stories.tsx
index 58c3992..f0cc5e8 100644
--- a/src/components/layout/app-shell.stories.tsx
+++ b/src/components/layout/app-shell.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
-import { expect, userEvent, within } from "storybook/test";
+import { expect, userEvent, waitFor, within } from "storybook/test";
import { AppShell } from "@/components/layout/app-shell";
@@ -67,13 +67,16 @@ export const MobileNavigation: Story = {
await userEvent.click(trigger);
const page = within(canvasElement.ownerDocument.body);
- await expect(page.getByRole("dialog")).toBeVisible();
- await expect(
- page.getByRole("navigation", { name: "Mobile application" }),
- ).toBeVisible();
+ const dialog = page.getByRole("dialog");
+ await waitFor(() => expect(dialog).toBeVisible());
+ await waitFor(() =>
+ expect(
+ page.getByRole("navigation", { name: "Mobile application" }),
+ ).toBeVisible(),
+ );
await userEvent.keyboard("{Escape}");
- await expect(trigger).toHaveFocus();
+ await waitFor(() => expect(trigger).toHaveFocus());
},
};
From e9d035131737e01883b0ec892f3682e9e2d9da62 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 09:15:48 +0700
Subject: [PATCH 37/45] harden server-only clerk config boundary
---
src/lib/clerk-config.server.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/lib/clerk-config.server.ts b/src/lib/clerk-config.server.ts
index 35d310f..94bfe69 100644
--- a/src/lib/clerk-config.server.ts
+++ b/src/lib/clerk-config.server.ts
@@ -1,3 +1,5 @@
+import "server-only";
+
import {
classifyClerkConfiguration,
type ClerkConfiguration,
From 177afa90a9f03970cb749da78fc202bbc8440940 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 09:16:04 +0700
Subject: [PATCH 38/45] harden server-only auth session boundary
---
src/lib/auth-session.server.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/lib/auth-session.server.ts b/src/lib/auth-session.server.ts
index 9d91ac7..21624cf 100644
--- a/src/lib/auth-session.server.ts
+++ b/src/lib/auth-session.server.ts
@@ -1,3 +1,5 @@
+import "server-only";
+
import { auth } from "@clerk/nextjs/server";
export async function getClerkSessionState() {
From f53ba55120b4d1b8e98313e356b97f3fcbec1650 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 09:16:31 +0700
Subject: [PATCH 39/45] align clerk key validation with documented formats
---
src/lib/clerk-config.ts | 66 ++++++++++++++++++++++++++++++++++-------
1 file changed, 56 insertions(+), 10 deletions(-)
diff --git a/src/lib/clerk-config.ts b/src/lib/clerk-config.ts
index ab4df3e..b40f3f8 100644
--- a/src/lib/clerk-config.ts
+++ b/src/lib/clerk-config.ts
@@ -7,6 +7,7 @@ type ClerkEnvironmentKey =
(typeof CLERK_ENV_KEYS)[keyof typeof CLERK_ENV_KEYS];
type ClerkInstanceEnvironment = "test" | "live";
+type ClerkKeyKind = "pk" | "sk";
export type ClerkConfiguration =
| {
@@ -33,9 +34,6 @@ type ClerkEnvironmentInput = {
secretKey?: string;
};
-const publishableKeyPattern = /^pk_(test|live)_[A-Za-z0-9_-]{16,}$/;
-const secretKeyPattern = /^sk_(test|live)_[A-Za-z0-9_-]{16,}$/;
-
function normalize(value: string | undefined) {
return value?.trim() ?? "";
}
@@ -52,6 +50,55 @@ function isPlaceholder(value: string) {
);
}
+function parsePrefixedEnvironment(
+ value: string,
+ kind: ClerkKeyKind,
+): ClerkInstanceEnvironment | null {
+ for (const environment of ["test", "live"] as const) {
+ const prefix = `${kind}_${environment}_`;
+ if (value.startsWith(prefix) && value.length > prefix.length) {
+ return environment;
+ }
+ }
+
+ return null;
+}
+
+function parsePublishableKeyEnvironment(
+ publishableKey: string,
+): ClerkInstanceEnvironment | null {
+ const environment = parsePrefixedEnvironment(publishableKey, "pk");
+ if (!environment) {
+ return null;
+ }
+
+ const encodedFrontendApi = publishableKey.slice(
+ `pk_${environment}_`.length,
+ );
+
+ try {
+ const decodedFrontendApi = atob(encodedFrontendApi);
+ if (
+ decodedFrontendApi.length <= 1 ||
+ !decodedFrontendApi.endsWith("$")
+ ) {
+ return null;
+ }
+ } catch {
+ return null;
+ }
+
+ return environment;
+}
+
+function parseSecretKeyEnvironment(
+ secretKey: string,
+): ClerkInstanceEnvironment | null {
+ // Clerk documents the environment prefixes, but the remaining secret payload
+ // is opaque. Do not impose an undocumented charset or minimum length on it.
+ return parsePrefixedEnvironment(secretKey, "sk");
+}
+
export function classifyClerkConfiguration(
input: ClerkEnvironmentInput,
): ClerkConfiguration {
@@ -80,14 +127,15 @@ export function classifyClerkConfiguration(
return { status: "placeholder", keys: placeholderKeys };
}
- const publishableMatch = publishableKey.match(publishableKeyPattern);
- const secretMatch = secretKey.match(secretKeyPattern);
- if (!publishableMatch || !secretMatch) {
+ const publishableEnvironment =
+ parsePublishableKeyEnvironment(publishableKey);
+ const secretEnvironment = parseSecretKeyEnvironment(secretKey);
+ if (!publishableEnvironment || !secretEnvironment) {
const malformedKeys: ClerkEnvironmentKey[] = [];
- if (!publishableMatch) {
+ if (!publishableEnvironment) {
malformedKeys.push(CLERK_ENV_KEYS.publishableKey);
}
- if (!secretMatch) {
+ if (!secretEnvironment) {
malformedKeys.push(CLERK_ENV_KEYS.secretKey);
}
@@ -98,8 +146,6 @@ export function classifyClerkConfiguration(
};
}
- const publishableEnvironment = publishableMatch[1] as ClerkInstanceEnvironment;
- const secretEnvironment = secretMatch[1] as ClerkInstanceEnvironment;
if (publishableEnvironment !== secretEnvironment) {
return {
status: "malformed",
From 32036b8815ed051290597e81f4941ab75a5f1ab2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 09:16:58 +0700
Subject: [PATCH 40/45] harden clerk configuration fixtures and coverage
---
src/lib/clerk-config.test.ts | 34 +++++++++++++++++++++++++++-------
1 file changed, 27 insertions(+), 7 deletions(-)
diff --git a/src/lib/clerk-config.test.ts b/src/lib/clerk-config.test.ts
index 37f5616..ac6702f 100644
--- a/src/lib/clerk-config.test.ts
+++ b/src/lib/clerk-config.test.ts
@@ -1,13 +1,19 @@
+import { Buffer } from "node:buffer";
+
import { describe, expect, it } from "vitest";
import { classifyClerkConfiguration } from "@/lib/clerk-config";
-const publishableTestKey = `pk_test_${"a".repeat(24)}`;
-const secretTestKey = `sk_test_${"b".repeat(24)}`;
-const publishableLiveKey = `pk_live_${"c".repeat(24)}`;
+const frontendApiDomain = "bridgeworks-test.accounts.dev";
+const encodedFrontendApi = Buffer.from(`${frontendApiDomain}$`, "utf8").toString(
+ "base64",
+);
+const publishableTestKey = `pk_test_${encodedFrontendApi}`;
+const publishableLiveKey = `pk_live_${encodedFrontendApi}`;
+const secretTestKey = "sk_test_opaque:fixture/with+punctuation=";
- describe("classifyClerkConfiguration", () => {
- it("classifies matching valid keys as configured without returning the secret", () => {
+describe("classifyClerkConfiguration", () => {
+ it("accepts an official-shaped test key pair without returning the secret", () => {
const result = classifyClerkConfiguration({
publishableKey: ` ${publishableTestKey} `,
secretKey: ` ${secretTestKey} `,
@@ -19,6 +25,7 @@ const publishableLiveKey = `pk_live_${"c".repeat(24)}`;
environment: "test",
});
expect(result).not.toHaveProperty("secretKey");
+ expect(JSON.stringify(result)).not.toContain(secretTestKey);
});
it("reports every missing key", () => {
@@ -58,7 +65,7 @@ const publishableLiveKey = `pk_live_${"c".repeat(24)}`;
});
});
- it("classifies invalid key formats as malformed", () => {
+ it("classifies an invalid publishable key as malformed", () => {
expect(
classifyClerkConfiguration({
publishableKey: "publishable-value",
@@ -71,7 +78,20 @@ const publishableLiveKey = `pk_live_${"c".repeat(24)}`;
});
});
- it("rejects test and live keys from different Clerk instances", () => {
+ it("classifies a Secret Key without an opaque payload as malformed", () => {
+ expect(
+ classifyClerkConfiguration({
+ publishableKey: publishableTestKey,
+ secretKey: "sk_test_",
+ }),
+ ).toEqual({
+ status: "malformed",
+ keys: ["CLERK_SECRET_KEY"],
+ reason: "invalid_format",
+ });
+ });
+
+ it("rejects a live publishable key paired with a test Secret Key", () => {
expect(
classifyClerkConfiguration({
publishableKey: publishableLiveKey,
From c3024ec53140a019ed2d6d7500b35263c8b9c6e4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 09:17:40 +0700
Subject: [PATCH 41/45] document authenticated smoke hardening
---
README.md | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 7465a96..2a574f5 100644
--- a/README.md
+++ b/README.md
@@ -50,12 +50,14 @@ CLERK_SECRET_KEY=sk_test_replace_me
`src/lib/clerk-config.ts` classifies the Clerk key pair as one of:
-- `configured` — both keys have valid formats and belong to the same test or live environment;
+- `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.
-`src/lib/clerk-config.server.ts` is the only environment-reading boundary. It validates the secret but never returns, logs, renders, or serializes it. The root layout receives only the publishable key when configuration is valid.
+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:
@@ -124,6 +126,19 @@ Without a dedicated Clerk test instance, CI covers:
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
Agents must read these files before changing UI code:
From 769905636b37053ab7750a2833397b88b1f58719 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 09:17:53 +0700
Subject: [PATCH 42/45] verify server-only client import rejection
---
src/app/__server-only-verification/page.tsx | 11 +++++++++++
1 file changed, 11 insertions(+)
create mode 100644 src/app/__server-only-verification/page.tsx
diff --git a/src/app/__server-only-verification/page.tsx b/src/app/__server-only-verification/page.tsx
new file mode 100644
index 0000000..397107c
--- /dev/null
+++ b/src/app/__server-only-verification/page.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import { getClerkSessionState } from "@/lib/auth-session.server";
+import { getClerkConfiguration } from "@/lib/clerk-config.server";
+
+export default function ServerOnlyVerificationPage() {
+ void getClerkConfiguration;
+ void getClerkSessionState;
+
+ return null;
+}
From 968c3cf35bd5119f1740ae47612fdc636d23f8ba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 09:23:59 +0700
Subject: [PATCH 43/45] remove ignored server-only verification fixture
---
src/app/__server-only-verification/page.tsx | 11 -----------
1 file changed, 11 deletions(-)
delete mode 100644 src/app/__server-only-verification/page.tsx
diff --git a/src/app/__server-only-verification/page.tsx b/src/app/__server-only-verification/page.tsx
deleted file mode 100644
index 397107c..0000000
--- a/src/app/__server-only-verification/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-"use client";
-
-import { getClerkSessionState } from "@/lib/auth-session.server";
-import { getClerkConfiguration } from "@/lib/clerk-config.server";
-
-export default function ServerOnlyVerificationPage() {
- void getClerkConfiguration;
- void getClerkSessionState;
-
- return null;
-}
From cf14d0848f377f29ae0a16fd6d3f4e0707be4b57 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 09:24:15 +0700
Subject: [PATCH 44/45] verify routable server-only client import rejection
---
src/app/server-only-verification/page.tsx | 11 +++++++++++
1 file changed, 11 insertions(+)
create mode 100644 src/app/server-only-verification/page.tsx
diff --git a/src/app/server-only-verification/page.tsx b/src/app/server-only-verification/page.tsx
new file mode 100644
index 0000000..f137b53
--- /dev/null
+++ b/src/app/server-only-verification/page.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import { getClerkSessionState } from "@/lib/auth-session.server";
+import { getClerkConfiguration } from "@/lib/clerk-config.server";
+
+export default function ServerOnlyVerificationPage() {
+ const configuration = getClerkConfiguration();
+ void getClerkSessionState();
+
+ return {configuration.status}
;
+}
From 3c1f3e9ab10c57ed504de83a18a7ba7d0fc72fc9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=90=E1=BB=97=20Minh=20H=C3=B9ng?=
<92613966+DoMinhHHung@users.noreply.github.com>
Date: Wed, 5 Aug 2026 09:26:23 +0700
Subject: [PATCH 45/45] remove server-only verification fixture
---
src/app/server-only-verification/page.tsx | 11 -----------
1 file changed, 11 deletions(-)
delete mode 100644 src/app/server-only-verification/page.tsx
diff --git a/src/app/server-only-verification/page.tsx b/src/app/server-only-verification/page.tsx
deleted file mode 100644
index f137b53..0000000
--- a/src/app/server-only-verification/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-"use client";
-
-import { getClerkSessionState } from "@/lib/auth-session.server";
-import { getClerkConfiguration } from "@/lib/clerk-config.server";
-
-export default function ServerOnlyVerificationPage() {
- const configuration = getClerkConfiguration();
- void getClerkSessionState();
-
- return {configuration.status}
;
-}