WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

feat(web): login/logout flow with session-aware UI (ATB-30) (#40)

* feat(web): implement login/logout flow with session-aware UI (ATB-30)

- Add session helper (lib/session.ts): getSession() fetches auth state
from AppView's /api/auth/session by forwarding the browser's Cookie
header server-to-server
- Add auth proxy (routes/auth.ts): proxies all /api/auth/* GET requests
to AppView (forwarding cookies + Set-Cookie headers), plus POST /logout
that revokes tokens and clears the session cookie
- Update BaseLayout to accept optional auth prop: renders "Log in" link
when unauthenticated, or handle + POST logout form when authenticated
- Implement full login page: handle input, AT Proto explanation, error
display from query param, redirects to / when already authenticated
- Convert all routes to factory functions createXRoutes(appviewUrl) so
session can be injected for auth-aware header rendering on every page
- Add auth-gated prompts: board page shows "Log in to start a topic",
topic page shows "Log in to reply" when unauthenticated
- Update fetchApi to accept cookieHeader option for forwarding cookies
in server-to-server API calls
- Add 30 new tests: session helper, auth proxy, login page, auth-aware
BaseLayout nav, updated route stubs with fetch mocking

* fix stuff

* fix(web): address ATB-30 PR review — error handling, logging, factory pattern

Critical fixes:
- auth proxy GET now catches AppView unreachable: login/callback redirect to
/login?error=..., session path returns 503 JSON
- logout bare catch replaced: logs network errors, re-throws programming errors,
checks logoutRes.ok and logs non-ok AppView responses
- decodeURIComponent in login.tsx wrapped in try-catch to avoid URIError crash
on malformed percent-encoding (e.g. %ZZ in query params)

Important fixes:
- getSession logs console.error for network/unexpected errors and for non-ok
non-401 AppView responses (operators can now distinguish AppView downtime from
normal expired sessions)
- createLoginRoutes and createAuthRoutes now accept appviewUrl param — consistent
factory DI pattern used by all other routes; routes/index.ts updated
- fetchApi wraps fetch() in try-catch and throws descriptive network error

Test coverage:
- Authenticated UI branch tests added for boards, topics, new-topic, home header
- auth.test.ts: AppView unreachable redirects/503, logout non-ok logging
- session.test.ts: non-ok non-401 logging, network error logging, 401 no-log
- api.test.ts: cookieHeader forwarding, network error message

* fix(web): address ATB-30 re-review — dead exports, TypeError guard, new tests

- Remove dead `export const` aliases (homeRoutes, boardsRoutes, topicsRoutes,
newTopicRoutes) — index.ts correctly uses factory functions + loadConfig;
the module-level aliases duplicated config logic and could mislead
- Add TypeError to logout re-throw guard per CLAUDE.md: programming TypeErrors
(e.g. fetch(undefined), bad URL) now propagate instead of being silently
logged as "network errors"; existing network failure tests use new Error(),
not TypeError, so graceful logout on ECONNREFUSED is unaffected
- Add test: ReferenceError from fetch() propagates out of logout handler —
Hono returns 500, cookie is not cleared
- Add test: GET /login?error=%ZZ returns 200 with %ZZ in error banner instead
of 500; verifies the decodeURIComponent try-catch fallback works
- Add JSDoc to fetchApi documenting the two error shapes callers must classify
(network error → 503, API error → map HTTP status)

* remove proxied auth routes

* we still need the auth routes, but just the logout

authored by

Malpercio and committed by
GitHub
6298fa17 69f45f21

+1063 -78
+5 -3
.env.example
··· 16 16 FORUM_PASSWORD=your-forum-password 17 17 18 18 # OAuth Configuration 19 - OAUTH_PUBLIC_URL=http://localhost:3000 20 - # The public URL where your AppView is accessible (used for client_id and redirect_uri) 19 + OAUTH_PUBLIC_URL=http://localhost:8080 20 + # The public URL where the forum is accessible (used as OAuth client_id and redirect_uri base). 21 + # AT Protocol fetches {OAUTH_PUBLIC_URL}/.well-known/oauth-client-metadata — nginx routes 22 + # this to AppView, so OAUTH_PUBLIC_URL must be the nginx address (not AppView directly). 23 + # For local dev with devenv: http://localhost:8080 (devenv nginx runs on port 8080) 21 24 # For production: https://your-forum-domain.com 22 - # For local dev with ngrok: https://abc123.ngrok.io 23 25 24 26 SESSION_SECRET=CHANGE_ME_SEE_COMMENT_BELOW 25 27 # Used for signing session tokens (prevent tampering)
+58
apps/web/src/layouts/__tests__/base.test.tsx
··· 1 1 import { describe, it, expect } from "vitest"; 2 2 import { Hono } from "hono"; 3 3 import { BaseLayout } from "../base.js"; 4 + import type { WebSession } from "../../lib/session.js"; 4 5 5 6 const app = new Hono().get("/", (c) => 6 7 c.html(<BaseLayout title="Test Page">Page content</BaseLayout>) ··· 63 64 const html = await res.text(); 64 65 expect(html).toContain('href="/"'); 65 66 expect(html).toContain('class="site-header__title"'); 67 + }); 68 + 69 + describe("auth-aware navigation", () => { 70 + it("shows Log in link when auth is not provided (default unauthenticated)", async () => { 71 + const unauthApp = new Hono().get("/", (c) => 72 + c.html(<BaseLayout>content</BaseLayout>) 73 + ); 74 + const res = await unauthApp.request("/"); 75 + const html = await res.text(); 76 + expect(html).toContain('href="/login"'); 77 + expect(html).toContain("Log in"); 78 + }); 79 + 80 + it("shows Log in link when auth is explicitly unauthenticated", async () => { 81 + const auth: WebSession = { authenticated: false }; 82 + const unauthApp = new Hono().get("/", (c) => 83 + c.html(<BaseLayout auth={auth}>content</BaseLayout>) 84 + ); 85 + const res = await unauthApp.request("/"); 86 + const html = await res.text(); 87 + expect(html).toContain('href="/login"'); 88 + expect(html).toContain("Log in"); 89 + expect(html).not.toContain("Log out"); 90 + }); 91 + 92 + it("shows handle and Log out button when authenticated", async () => { 93 + const auth: WebSession = { 94 + authenticated: true, 95 + did: "did:plc:abc123", 96 + handle: "alice.bsky.social", 97 + }; 98 + const authApp = new Hono().get("/", (c) => 99 + c.html(<BaseLayout auth={auth}>content</BaseLayout>) 100 + ); 101 + const res = await authApp.request("/"); 102 + const html = await res.text(); 103 + expect(html).toContain("alice.bsky.social"); 104 + expect(html).toContain("Log out"); 105 + expect(html).not.toContain('href="/login"'); 106 + }); 107 + 108 + it("renders logout as a form POST (not a link)", async () => { 109 + const auth: WebSession = { 110 + authenticated: true, 111 + did: "did:plc:abc123", 112 + handle: "alice.bsky.social", 113 + }; 114 + const authApp = new Hono().get("/", (c) => 115 + c.html(<BaseLayout auth={auth}>content</BaseLayout>) 116 + ); 117 + const res = await authApp.request("/"); 118 + const html = await res.text(); 119 + // Logout must be a form POST for CSRF protection, not a plain link 120 + expect(html).toContain('action="/logout"'); 121 + expect(html).toContain('method="post"'); 122 + expect(html).toContain("Log out"); 123 + }); 66 124 }); 67 125 });
+23 -2
apps/web/src/layouts/base.tsx
··· 1 1 import type { FC, PropsWithChildren } from "hono/jsx"; 2 2 import { tokensToCss } from "../lib/theme.js"; 3 3 import { neobrutalLight } from "../styles/presets/neobrutal-light.js"; 4 + import type { WebSession } from "../lib/session.js"; 4 5 5 6 const ROOT_CSS = `:root { ${tokensToCss(neobrutalLight)} }`; 6 7 7 - export const BaseLayout: FC<PropsWithChildren<{ title?: string }>> = (props) => { 8 + export const BaseLayout: FC< 9 + PropsWithChildren<{ title?: string; auth?: WebSession }> 10 + > = (props) => { 11 + const { auth } = props; 8 12 return ( 9 13 <html lang="en"> 10 14 <head> ··· 28 32 atBB Forum 29 33 </a> 30 34 <nav class="site-header__nav"> 31 - {/* login/logout — auth ticket */} 35 + {auth?.authenticated ? ( 36 + <> 37 + <span class="site-header__handle">{auth.handle}</span> 38 + <form 39 + action="/logout" 40 + method="post" 41 + class="site-header__logout-form" 42 + > 43 + <button type="submit" class="site-header__logout-btn"> 44 + Log out 45 + </button> 46 + </form> 47 + </> 48 + ) : ( 49 + <a href="/login" class="site-header__login-link"> 50 + Log in 51 + </a> 52 + )} 32 53 </nav> 33 54 </div> 34 55 </header>
+37
apps/web/src/lib/__tests__/api.test.ts
··· 71 71 "AppView API error: 404 Not Found" 72 72 ); 73 73 }); 74 + 75 + it("forwards cookieHeader as Cookie header when provided", async () => { 76 + mockFetch.mockResolvedValueOnce({ 77 + ok: true, 78 + json: () => Promise.resolve({}), 79 + }); 80 + 81 + const fetchApi = await loadFetchApi(); 82 + await fetchApi("/boards", { cookieHeader: "atbb_session=mytoken" }); 83 + 84 + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 85 + expect((init.headers as Record<string, string>)["Cookie"]).toBe( 86 + "atbb_session=mytoken" 87 + ); 88 + }); 89 + 90 + it("does not set Cookie header when cookieHeader is not provided", async () => { 91 + mockFetch.mockResolvedValueOnce({ 92 + ok: true, 93 + json: () => Promise.resolve({}), 94 + }); 95 + 96 + const fetchApi = await loadFetchApi(); 97 + await fetchApi("/boards"); 98 + 99 + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 100 + expect((init.headers as Record<string, string>)["Cookie"]).toBeUndefined(); 101 + }); 102 + 103 + it("throws a network error with descriptive message when AppView is unreachable", async () => { 104 + mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED")); 105 + 106 + const fetchApi = await loadFetchApi(); 107 + await expect(fetchApi("/boards")).rejects.toThrow( 108 + "AppView network error: fetch failed: ECONNREFUSED" 109 + ); 110 + }); 74 111 });
+183
apps/web/src/lib/__tests__/session.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + import { getSession } from "../session.js"; 3 + 4 + const mockFetch = vi.fn(); 5 + 6 + describe("getSession", () => { 7 + beforeEach(() => { 8 + vi.stubGlobal("fetch", mockFetch); 9 + }); 10 + 11 + afterEach(() => { 12 + vi.unstubAllGlobals(); 13 + mockFetch.mockReset(); 14 + }); 15 + 16 + it("returns unauthenticated when no cookie header provided", async () => { 17 + const result = await getSession("http://localhost:3000"); 18 + expect(result).toEqual({ authenticated: false }); 19 + expect(mockFetch).not.toHaveBeenCalled(); 20 + }); 21 + 22 + it("returns unauthenticated when cookie header has no atbb_session", async () => { 23 + const result = await getSession( 24 + "http://localhost:3000", 25 + "other_cookie=value" 26 + ); 27 + expect(result).toEqual({ authenticated: false }); 28 + expect(mockFetch).not.toHaveBeenCalled(); 29 + }); 30 + 31 + it("calls AppView /api/auth/session with forwarded cookie header", async () => { 32 + mockFetch.mockResolvedValueOnce({ 33 + ok: true, 34 + json: () => 35 + Promise.resolve({ 36 + authenticated: true, 37 + did: "did:plc:abc123", 38 + handle: "alice.bsky.social", 39 + }), 40 + }); 41 + 42 + await getSession( 43 + "http://localhost:3000", 44 + "atbb_session=some-token; other=value" 45 + ); 46 + 47 + expect(mockFetch).toHaveBeenCalledOnce(); 48 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 49 + expect(url).toBe("http://localhost:3000/api/auth/session"); 50 + expect((init.headers as Record<string, string>)["Cookie"]).toBe( 51 + "atbb_session=some-token; other=value" 52 + ); 53 + }); 54 + 55 + it("returns authenticated session with did and handle on success", async () => { 56 + mockFetch.mockResolvedValueOnce({ 57 + ok: true, 58 + json: () => 59 + Promise.resolve({ 60 + authenticated: true, 61 + did: "did:plc:abc123", 62 + handle: "alice.bsky.social", 63 + }), 64 + }); 65 + 66 + const result = await getSession( 67 + "http://localhost:3000", 68 + "atbb_session=token" 69 + ); 70 + 71 + expect(result).toEqual({ 72 + authenticated: true, 73 + did: "did:plc:abc123", 74 + handle: "alice.bsky.social", 75 + }); 76 + }); 77 + 78 + it("returns unauthenticated when AppView returns 401 (expired session)", async () => { 79 + mockFetch.mockResolvedValueOnce({ 80 + ok: false, 81 + status: 401, 82 + }); 83 + 84 + const result = await getSession( 85 + "http://localhost:3000", 86 + "atbb_session=expired" 87 + ); 88 + 89 + expect(result).toEqual({ authenticated: false }); 90 + }); 91 + 92 + it("logs console.error when AppView returns unexpected non-ok status (not 401)", async () => { 93 + const consoleSpy = vi 94 + .spyOn(console, "error") 95 + .mockImplementation(() => {}); 96 + mockFetch.mockResolvedValueOnce({ 97 + ok: false, 98 + status: 500, 99 + }); 100 + 101 + const result = await getSession( 102 + "http://localhost:3000", 103 + "atbb_session=token" 104 + ); 105 + 106 + expect(result).toEqual({ authenticated: false }); 107 + expect(consoleSpy).toHaveBeenCalledWith( 108 + expect.stringContaining("unexpected non-ok status"), 109 + expect.objectContaining({ status: 500 }) 110 + ); 111 + 112 + consoleSpy.mockRestore(); 113 + }); 114 + 115 + it("does not log console.error for 401 (normal expired session)", async () => { 116 + const consoleSpy = vi 117 + .spyOn(console, "error") 118 + .mockImplementation(() => {}); 119 + mockFetch.mockResolvedValueOnce({ 120 + ok: false, 121 + status: 401, 122 + }); 123 + 124 + await getSession("http://localhost:3000", "atbb_session=expired"); 125 + 126 + expect(consoleSpy).not.toHaveBeenCalled(); 127 + 128 + consoleSpy.mockRestore(); 129 + }); 130 + 131 + it("returns unauthenticated when AppView response is malformed", async () => { 132 + mockFetch.mockResolvedValueOnce({ 133 + ok: true, 134 + json: () => 135 + Promise.resolve({ 136 + authenticated: true, 137 + // missing did and handle fields 138 + }), 139 + }); 140 + 141 + const result = await getSession( 142 + "http://localhost:3000", 143 + "atbb_session=token" 144 + ); 145 + 146 + expect(result).toEqual({ authenticated: false }); 147 + }); 148 + 149 + it("returns unauthenticated and logs when AppView is unreachable (network error)", async () => { 150 + const consoleSpy = vi 151 + .spyOn(console, "error") 152 + .mockImplementation(() => {}); 153 + mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED")); 154 + 155 + const result = await getSession( 156 + "http://localhost:3000", 157 + "atbb_session=token" 158 + ); 159 + 160 + expect(result).toEqual({ authenticated: false }); 161 + expect(consoleSpy).toHaveBeenCalledWith( 162 + expect.stringContaining("network or unexpected error"), 163 + expect.objectContaining({ error: expect.stringContaining("ECONNREFUSED") }) 164 + ); 165 + 166 + consoleSpy.mockRestore(); 167 + }); 168 + 169 + it("returns unauthenticated when AppView returns authenticated:false", async () => { 170 + mockFetch.mockResolvedValueOnce({ 171 + ok: false, 172 + status: 401, 173 + json: () => Promise.resolve({ authenticated: false }), 174 + }); 175 + 176 + const result = await getSession( 177 + "http://localhost:3000", 178 + "atbb_session=token" 179 + ); 180 + 181 + expect(result).toEqual({ authenticated: false }); 182 + }); 183 + });
+25 -2
apps/web/src/lib/api.ts
··· 2 2 3 3 const config = loadConfig(); 4 4 5 - export async function fetchApi<T>(path: string): Promise<T> { 5 + /** 6 + * Fetches from the AppView API and returns parsed JSON. 7 + * 8 + * Throws two distinct error shapes that callers must classify: 9 + * - `"AppView network error: ..."` — AppView is unreachable; callers should 10 + * return 503 so the user knows to retry. 11 + * - `"AppView API error: N ..."` — AppView returned a non-ok HTTP status; 12 + * callers should map to an appropriate response (404, 400, 500, etc.). 13 + */ 14 + export async function fetchApi<T>( 15 + path: string, 16 + options?: { cookieHeader?: string } 17 + ): Promise<T> { 6 18 const url = `${config.appviewUrl}/api${path}`; 7 - const res = await fetch(url); 19 + const headers: Record<string, string> = {}; 20 + if (options?.cookieHeader) { 21 + headers["Cookie"] = options.cookieHeader; 22 + } 23 + let res: Response; 24 + try { 25 + res = await fetch(url, { headers }); 26 + } catch (error) { 27 + throw new Error( 28 + `AppView network error: ${error instanceof Error ? error.message : String(error)}` 29 + ); 30 + } 8 31 if (!res.ok) { 9 32 throw new Error(`AppView API error: ${res.status} ${res.statusText}`); 10 33 }
+57
apps/web/src/lib/session.ts
··· 1 + export type WebSession = 2 + | { authenticated: false } 3 + | { authenticated: true; did: string; handle: string }; 4 + 5 + /** 6 + * Fetches the current session from AppView by forwarding the browser's 7 + * atbb_session cookie in a server-to-server call. 8 + * 9 + * Returns unauthenticated if no cookie is present, AppView is unreachable, 10 + * or the session is invalid. 11 + */ 12 + export async function getSession( 13 + appviewUrl: string, 14 + cookieHeader?: string 15 + ): Promise<WebSession> { 16 + if (!cookieHeader || !cookieHeader.includes("atbb_session=")) { 17 + return { authenticated: false }; 18 + } 19 + 20 + try { 21 + const res = await fetch(`${appviewUrl}/api/auth/session`, { 22 + headers: { Cookie: cookieHeader }, 23 + }); 24 + 25 + if (!res.ok) { 26 + if (res.status !== 401) { 27 + console.error("getSession: unexpected non-ok status from AppView", { 28 + operation: "GET /api/auth/session", 29 + status: res.status, 30 + }); 31 + } 32 + return { authenticated: false }; 33 + } 34 + 35 + const data = (await res.json()) as Record<string, unknown>; 36 + 37 + if ( 38 + data.authenticated === true && 39 + typeof data.did === "string" && 40 + typeof data.handle === "string" 41 + ) { 42 + return { authenticated: true, did: data.did, handle: data.handle }; 43 + } 44 + 45 + return { authenticated: false }; 46 + } catch (error) { 47 + console.error( 48 + "getSession: network or unexpected error — treating as unauthenticated", 49 + { 50 + operation: "GET /api/auth/session", 51 + error: error instanceof Error ? error.message : String(error), 52 + } 53 + ); 54 + // AppView unavailable or network error — treat as unauthenticated 55 + return { authenticated: false }; 56 + } 57 + }
+122
apps/web/src/routes/__tests__/auth.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + const mockFetch = vi.fn(); 4 + 5 + describe("createAuthRoutes", () => { 6 + beforeEach(() => { 7 + vi.stubGlobal("fetch", mockFetch); 8 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 9 + vi.resetModules(); 10 + }); 11 + 12 + afterEach(() => { 13 + vi.unstubAllGlobals(); 14 + vi.unstubAllEnvs(); 15 + mockFetch.mockReset(); 16 + }); 17 + 18 + async function loadAuthRoutes() { 19 + const mod = await import("../auth.js"); 20 + return mod.createAuthRoutes("http://localhost:3000"); 21 + } 22 + 23 + describe("POST /logout", () => { 24 + it("calls AppView logout, clears cookie, and redirects to /", async () => { 25 + mockFetch.mockResolvedValueOnce({ 26 + ok: true, 27 + status: 200, 28 + }); 29 + 30 + const authRoutes = await loadAuthRoutes(); 31 + const res = await authRoutes.request("/logout", { 32 + method: "POST", 33 + headers: { cookie: "atbb_session=user-token" }, 34 + }); 35 + 36 + expect(res.status).toBe(303); 37 + expect(res.headers.get("location")).toBe("/"); 38 + 39 + // Verify AppView logout was called with the forwarded cookie 40 + expect(mockFetch).toHaveBeenCalledOnce(); 41 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 42 + expect(url).toBe("http://localhost:3000/api/auth/logout"); 43 + // Full Cookie header forwarded verbatim 44 + expect((init.headers as Record<string, string>)["Cookie"]).toBe( 45 + "atbb_session=user-token" 46 + ); 47 + }); 48 + 49 + it("clears atbb_session cookie via Set-Cookie header", async () => { 50 + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); 51 + 52 + const authRoutes = await loadAuthRoutes(); 53 + const res = await authRoutes.request("/logout", { method: "POST" }); 54 + 55 + const setCookie = res.headers.get("set-cookie"); 56 + expect(setCookie).toContain("atbb_session="); 57 + expect(setCookie).toContain("Max-Age=0"); 58 + }); 59 + 60 + it("still clears cookie and redirects even if AppView logout fails", async () => { 61 + mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED")); 62 + 63 + const authRoutes = await loadAuthRoutes(); 64 + const res = await authRoutes.request("/logout", { method: "POST" }); 65 + 66 + // Should still redirect home (graceful degradation) 67 + expect(res.status).toBe(303); 68 + expect(res.headers.get("location")).toBe("/"); 69 + const setCookie = res.headers.get("set-cookie"); 70 + expect(setCookie).toContain("Max-Age=0"); 71 + }); 72 + 73 + it("logs console.error when AppView logout returns non-ok status", async () => { 74 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); 75 + const consoleSpy = vi 76 + .spyOn(console, "error") 77 + .mockImplementation(() => {}); 78 + 79 + const authRoutes = await loadAuthRoutes(); 80 + const res = await authRoutes.request("/logout", { method: "POST" }); 81 + 82 + // Still redirects home despite non-ok response 83 + expect(res.status).toBe(303); 84 + expect(consoleSpy).toHaveBeenCalledWith( 85 + expect.stringContaining("non-ok status"), 86 + expect.objectContaining({ status: 500 }) 87 + ); 88 + 89 + consoleSpy.mockRestore(); 90 + }); 91 + 92 + it("re-throws programming errors (ReferenceError) without clearing the cookie", async () => { 93 + mockFetch.mockRejectedValueOnce(new ReferenceError("fetch is not defined")); 94 + 95 + const authRoutes = await loadAuthRoutes(); 96 + const res = await authRoutes.request("/logout", { method: "POST" }); 97 + 98 + // Programming error escapes the catch block — Hono's default handler returns 500 99 + // rather than the expected 303 redirect, and the cookie is never cleared 100 + expect(res.status).toBe(500); 101 + expect(res.headers.get("set-cookie")).toBeNull(); 102 + }); 103 + 104 + it("logs console.error when AppView logout throws a network error", async () => { 105 + mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED")); 106 + const consoleSpy = vi 107 + .spyOn(console, "error") 108 + .mockImplementation(() => {}); 109 + 110 + const authRoutes = await loadAuthRoutes(); 111 + const res = await authRoutes.request("/logout", { method: "POST" }); 112 + 113 + expect(res.status).toBe(303); 114 + expect(consoleSpy).toHaveBeenCalledWith( 115 + expect.stringContaining("Failed to call AppView logout"), 116 + expect.objectContaining({ error: expect.stringContaining("ECONNREFUSED") }) 117 + ); 118 + 119 + consoleSpy.mockRestore(); 120 + }); 121 + }); 122 + });
+124
apps/web/src/routes/__tests__/login.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + const mockFetch = vi.fn(); 4 + 5 + describe("loginRoutes", () => { 6 + beforeEach(() => { 7 + vi.stubGlobal("fetch", mockFetch); 8 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 9 + vi.resetModules(); 10 + 11 + // Default: user is not authenticated 12 + mockFetch.mockResolvedValue({ ok: false, status: 401 }); 13 + }); 14 + 15 + afterEach(() => { 16 + vi.unstubAllGlobals(); 17 + vi.unstubAllEnvs(); 18 + mockFetch.mockReset(); 19 + }); 20 + 21 + async function loadLoginRoutes() { 22 + const mod = await import("../login.js"); 23 + return mod.createLoginRoutes("http://localhost:3000"); 24 + } 25 + 26 + it("renders handle input form for unauthenticated users", async () => { 27 + const routes = await loadLoginRoutes(); 28 + const res = await routes.request("/login"); 29 + 30 + expect(res.status).toBe(200); 31 + const html = await res.text(); 32 + expect(html).toContain('name="handle"'); 33 + expect(html).toContain('type="text"'); 34 + expect(html).toContain('placeholder="alice.bsky.social"'); 35 + }); 36 + 37 + it("renders login submit button", async () => { 38 + const routes = await loadLoginRoutes(); 39 + const res = await routes.request("/login"); 40 + 41 + const html = await res.text(); 42 + expect(html).toContain("Log in with AT Proto"); 43 + expect(html).toContain('type="submit"'); 44 + }); 45 + 46 + it("form action points to /api/auth/login (the auth proxy)", async () => { 47 + const routes = await loadLoginRoutes(); 48 + const res = await routes.request("/login"); 49 + 50 + const html = await res.text(); 51 + expect(html).toContain('action="/api/auth/login"'); 52 + expect(html).toContain('method="get"'); 53 + }); 54 + 55 + it("renders AT Proto explanation text", async () => { 56 + const routes = await loadLoginRoutes(); 57 + const res = await routes.request("/login"); 58 + 59 + const html = await res.text(); 60 + // Should explain what AT Proto login means 61 + expect(html).toContain("AT Protocol"); 62 + }); 63 + 64 + it("displays decoded error message from query param", async () => { 65 + const routes = await loadLoginRoutes(); 66 + const res = await routes.request( 67 + "/login?error=Invalid%20handle%20or%20unable%20to%20find%20your%20PDS." 68 + ); 69 + 70 + expect(res.status).toBe(200); 71 + const html = await res.text(); 72 + expect(html).toContain("Invalid handle or unable to find your PDS."); 73 + }); 74 + 75 + it("shows Log in link in header when unauthenticated", async () => { 76 + const routes = await loadLoginRoutes(); 77 + const res = await routes.request("/login"); 78 + 79 + const html = await res.text(); 80 + expect(html).toContain('href="/login"'); 81 + expect(html).toContain("Log in"); 82 + expect(html).not.toContain("Log out"); 83 + }); 84 + 85 + it("redirects to / when already authenticated", async () => { 86 + mockFetch.mockResolvedValueOnce({ 87 + ok: true, 88 + json: () => 89 + Promise.resolve({ 90 + authenticated: true, 91 + did: "did:plc:xyz", 92 + handle: "bob.bsky.social", 93 + }), 94 + }); 95 + 96 + const routes = await loadLoginRoutes(); 97 + const res = await routes.request("/login", { 98 + headers: { cookie: "atbb_session=valid-token" }, 99 + }); 100 + 101 + expect(res.status).toBe(302); 102 + expect(res.headers.get("location")).toBe("/"); 103 + }); 104 + 105 + it("displays raw error string when error param has malformed percent-encoding", async () => { 106 + const routes = await loadLoginRoutes(); 107 + // %ZZ is invalid percent-encoding — decodeURIComponent would throw URIError 108 + const res = await routes.request("/login?error=%ZZ"); 109 + 110 + expect(res.status).toBe(200); 111 + const html = await res.text(); 112 + // Falls back to raw string instead of crashing with 500 113 + expect(html).toContain("%ZZ"); 114 + expect(html).toContain("login-form__error"); 115 + }); 116 + 117 + it("renders no error banner when no error query param present", async () => { 118 + const routes = await loadLoginRoutes(); 119 + const res = await routes.request("/login"); 120 + 121 + const html = await res.text(); 122 + expect(html).not.toContain("login-form__error"); 123 + }); 124 + });
+145 -13
apps/web/src/routes/__tests__/stubs.test.tsx
··· 1 - import { describe, it, expect } from "vitest"; 2 - import { homeRoutes } from "../home.js"; 3 - import { boardsRoutes } from "../boards.js"; 4 - import { topicsRoutes } from "../topics.js"; 5 - import { loginRoutes } from "../login.js"; 6 - import { newTopicRoutes } from "../new-topic.js"; 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + const mockFetch = vi.fn(); 7 4 5 + /** 6 + * Route stub tests — verify each page route renders without error. 7 + * 8 + * Since routes now call getSession() (which calls AppView's /api/auth/session), 9 + * we mock fetch to return unauthenticated. This keeps tests fast and isolated 10 + * from external dependencies. 11 + */ 8 12 describe("Route stubs", () => { 13 + beforeEach(() => { 14 + vi.stubGlobal("fetch", mockFetch); 15 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 16 + vi.resetModules(); 17 + 18 + // Default: AppView session returns unauthenticated 19 + mockFetch.mockResolvedValue({ 20 + ok: false, 21 + status: 401, 22 + }); 23 + }); 24 + 25 + afterEach(() => { 26 + vi.unstubAllGlobals(); 27 + vi.unstubAllEnvs(); 28 + mockFetch.mockReset(); 29 + }); 30 + 31 + const authenticatedSession = { 32 + ok: true, 33 + json: () => 34 + Promise.resolve({ 35 + authenticated: true, 36 + did: "did:plc:abc", 37 + handle: "alice.bsky.social", 38 + }), 39 + }; 40 + 9 41 it("GET / returns 200 with home title", async () => { 10 - const res = await homeRoutes.request("/"); 42 + const { createHomeRoutes } = await import("../home.js"); 43 + const routes = createHomeRoutes("http://localhost:3000"); 44 + const res = await routes.request("/"); 11 45 expect(res.status).toBe(200); 12 46 const html = await res.text(); 13 47 expect(html).toContain("Home — atBB Forum"); 14 48 }); 15 49 50 + it("GET / shows handle in header when authenticated", async () => { 51 + mockFetch.mockResolvedValueOnce(authenticatedSession); 52 + const { createHomeRoutes } = await import("../home.js"); 53 + const routes = createHomeRoutes("http://localhost:3000"); 54 + const res = await routes.request("/", { 55 + headers: { cookie: "atbb_session=token" }, 56 + }); 57 + const html = await res.text(); 58 + expect(html).toContain("alice.bsky.social"); 59 + expect(html).toContain("Log out"); 60 + expect(html).not.toContain('href="/login"'); 61 + }); 62 + 16 63 it("GET /boards/:id returns 200 with board title", async () => { 17 - const res = await boardsRoutes.request("/boards/123"); 64 + const { createBoardsRoutes } = await import("../boards.js"); 65 + const routes = createBoardsRoutes("http://localhost:3000"); 66 + const res = await routes.request("/boards/123"); 18 67 expect(res.status).toBe(200); 19 68 const html = await res.text(); 20 69 expect(html).toContain("Board — atBB Forum"); 21 70 }); 22 71 72 + it("GET /boards/:id shows 'Log in to start a topic' when unauthenticated", async () => { 73 + const { createBoardsRoutes } = await import("../boards.js"); 74 + const routes = createBoardsRoutes("http://localhost:3000"); 75 + const res = await routes.request("/boards/123"); 76 + const html = await res.text(); 77 + expect(html).toContain("Log in"); 78 + expect(html).toContain("to start a topic"); 79 + expect(html).not.toContain("Start a new topic"); 80 + }); 81 + 82 + it("GET /boards/:id shows 'Start a new topic' link when authenticated", async () => { 83 + mockFetch.mockResolvedValueOnce(authenticatedSession); 84 + const { createBoardsRoutes } = await import("../boards.js"); 85 + const routes = createBoardsRoutes("http://localhost:3000"); 86 + const res = await routes.request("/boards/123", { 87 + headers: { cookie: "atbb_session=token" }, 88 + }); 89 + const html = await res.text(); 90 + expect(html).toContain("Start a new topic"); 91 + expect(html).not.toContain("Log in"); 92 + }); 93 + 23 94 it("GET /topics/:id returns 200 with topic title", async () => { 24 - const res = await topicsRoutes.request("/topics/123"); 95 + const { createTopicsRoutes } = await import("../topics.js"); 96 + const routes = createTopicsRoutes("http://localhost:3000"); 97 + const res = await routes.request("/topics/123"); 25 98 expect(res.status).toBe(200); 26 99 const html = await res.text(); 27 100 expect(html).toContain("Topic — atBB Forum"); 28 101 }); 29 102 30 - it("GET /login returns 200 with login title", async () => { 31 - const res = await loginRoutes.request("/login"); 103 + it("GET /topics/:id shows 'Log in to reply' when unauthenticated", async () => { 104 + const { createTopicsRoutes } = await import("../topics.js"); 105 + const routes = createTopicsRoutes("http://localhost:3000"); 106 + const res = await routes.request("/topics/123"); 107 + const html = await res.text(); 108 + expect(html).toContain("Log in"); 109 + expect(html).toContain("to reply"); 110 + expect(html).not.toContain("Reply form"); 111 + }); 112 + 113 + it("GET /topics/:id shows reply form placeholder when authenticated", async () => { 114 + mockFetch.mockResolvedValueOnce(authenticatedSession); 115 + const { createTopicsRoutes } = await import("../topics.js"); 116 + const routes = createTopicsRoutes("http://localhost:3000"); 117 + const res = await routes.request("/topics/123", { 118 + headers: { cookie: "atbb_session=token" }, 119 + }); 120 + const html = await res.text(); 121 + expect(html).toContain("Reply form will appear here"); 122 + expect(html).not.toContain("Log in"); 123 + }); 124 + 125 + it("GET /login returns 200 with sign in title", async () => { 126 + const { createLoginRoutes } = await import("../login.js"); 127 + const routes = createLoginRoutes("http://localhost:3000"); 128 + const res = await routes.request("/login"); 32 129 expect(res.status).toBe(200); 33 130 const html = await res.text(); 34 - expect(html).toContain("Login — atBB Forum"); 131 + expect(html).toContain("Sign in — atBB Forum"); 132 + }); 133 + 134 + it("GET /login redirects to / when user is already authenticated", async () => { 135 + mockFetch.mockResolvedValueOnce(authenticatedSession); 136 + const { createLoginRoutes } = await import("../login.js"); 137 + const routes = createLoginRoutes("http://localhost:3000"); 138 + const res = await routes.request("/login", { 139 + headers: { cookie: "atbb_session=token" }, 140 + }); 141 + expect(res.status).toBe(302); 142 + expect(res.headers.get("location")).toBe("/"); 35 143 }); 36 144 37 145 it("GET /new-topic returns 200 with new topic title", async () => { 38 - const res = await newTopicRoutes.request("/new-topic"); 146 + const { createNewTopicRoutes } = await import("../new-topic.js"); 147 + const routes = createNewTopicRoutes("http://localhost:3000"); 148 + const res = await routes.request("/new-topic"); 39 149 expect(res.status).toBe(200); 40 150 const html = await res.text(); 41 151 expect(html).toContain("New Topic — atBB Forum"); 152 + }); 153 + 154 + it("GET /new-topic shows 'Log in to create a topic' when unauthenticated", async () => { 155 + const { createNewTopicRoutes } = await import("../new-topic.js"); 156 + const routes = createNewTopicRoutes("http://localhost:3000"); 157 + const res = await routes.request("/new-topic"); 158 + const html = await res.text(); 159 + expect(html).toContain("Log in"); 160 + expect(html).toContain("to create a topic"); 161 + expect(html).not.toContain("Compose form"); 162 + }); 163 + 164 + it("GET /new-topic shows compose form placeholder when authenticated", async () => { 165 + mockFetch.mockResolvedValueOnce(authenticatedSession); 166 + const { createNewTopicRoutes } = await import("../new-topic.js"); 167 + const routes = createNewTopicRoutes("http://localhost:3000"); 168 + const res = await routes.request("/new-topic", { 169 + headers: { cookie: "atbb_session=token" }, 170 + }); 171 + const html = await res.text(); 172 + expect(html).toContain("Compose form will appear here"); 173 + expect(html).not.toContain("Log in"); 42 174 }); 43 175 });
+53
apps/web/src/routes/auth.ts
··· 1 + import { Hono } from "hono"; 2 + 3 + /** 4 + * POST /logout → calls AppView logout, clears cookie, redirects to / 5 + */ 6 + export function createAuthRoutes(appviewUrl: string) { 7 + return new Hono() 8 + /** 9 + * POST /logout — logout should be a POST (not a link) to prevent CSRF. 10 + * 11 + * Calls AppView's logout endpoint to revoke tokens and clean up the 12 + * server-side session, then clears the cookie on the web UI's domain and 13 + * redirects to the homepage. 14 + */ 15 + .post("/logout", async (c) => { 16 + const cookieHeader = c.req.header("cookie") ?? ""; 17 + 18 + try { 19 + const logoutRes = await fetch(`${appviewUrl}/api/auth/logout`, { 20 + headers: { Cookie: cookieHeader }, 21 + }); 22 + 23 + if (!logoutRes.ok) { 24 + console.error("Auth proxy: AppView logout returned non-ok status", { 25 + operation: "POST /logout", 26 + status: logoutRes.status, 27 + }); 28 + } 29 + } catch (error) { 30 + if ( 31 + error instanceof TypeError || 32 + error instanceof ReferenceError || 33 + error instanceof SyntaxError 34 + ) { 35 + throw error; // Re-throw programming errors — don't hide code bugs 36 + } 37 + console.error("Auth proxy: Failed to call AppView logout", { 38 + operation: "POST /logout", 39 + error: error instanceof Error ? error.message : String(error), 40 + }); 41 + // Continue — still clear local cookie 42 + } 43 + 44 + const headers = new Headers(); 45 + headers.set( 46 + "set-cookie", 47 + "atbb_session=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax" 48 + ); 49 + headers.set("location", "/"); 50 + 51 + return new Response(null, { status: 303, headers }); 52 + }); 53 + }
+21 -8
apps/web/src/routes/boards.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 3 import { PageHeader, EmptyState } from "../components/index.js"; 4 + import { getSession } from "../lib/session.js"; 4 5 5 - export const boardsRoutes = new Hono().get("/boards/:id", (c) => 6 - c.html( 7 - <BaseLayout title="Board — atBB Forum"> 8 - <PageHeader title="Board" description="Topics will appear here." /> 9 - <EmptyState message="No topics yet." /> 10 - </BaseLayout> 11 - ) 12 - ); 6 + export function createBoardsRoutes(appviewUrl: string) { 7 + return new Hono().get("/boards/:id", async (c) => { 8 + const auth = await getSession(appviewUrl, c.req.header("cookie")); 9 + return c.html( 10 + <BaseLayout title="Board — atBB Forum" auth={auth}> 11 + <PageHeader title="Board" description="Topics will appear here." /> 12 + {auth.authenticated ? ( 13 + <p> 14 + <a href="/new-topic">Start a new topic</a> 15 + </p> 16 + ) : ( 17 + <p> 18 + <a href="/login">Log in</a> to start a topic. 19 + </p> 20 + )} 21 + <EmptyState message="No topics yet." /> 22 + </BaseLayout> 23 + ); 24 + }); 25 + }
+15 -11
apps/web/src/routes/home.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 3 import { PageHeader, EmptyState } from "../components/index.js"; 4 + import { getSession } from "../lib/session.js"; 4 5 5 - export const homeRoutes = new Hono().get("/", (c) => 6 - c.html( 7 - <BaseLayout title="Home — atBB Forum"> 8 - <PageHeader 9 - title="Welcome to atBB" 10 - description="A BB-style forum on the ATmosphere." 11 - /> 12 - <EmptyState message="No boards yet." /> 13 - </BaseLayout> 14 - ) 15 - ); 6 + export function createHomeRoutes(appviewUrl: string) { 7 + return new Hono().get("/", async (c) => { 8 + const auth = await getSession(appviewUrl, c.req.header("cookie")); 9 + return c.html( 10 + <BaseLayout title="Home — atBB Forum" auth={auth}> 11 + <PageHeader 12 + title="Welcome to atBB" 13 + description="A BB-style forum on the ATmosphere." 14 + /> 15 + <EmptyState message="No boards yet." /> 16 + </BaseLayout> 17 + ); 18 + }); 19 + }
+15 -10
apps/web/src/routes/index.ts
··· 1 1 import { Hono } from "hono"; 2 - import { homeRoutes } from "./home.js"; 3 - import { boardsRoutes } from "./boards.js"; 4 - import { topicsRoutes } from "./topics.js"; 5 - import { loginRoutes } from "./login.js"; 6 - import { newTopicRoutes } from "./new-topic.js"; 2 + import { loadConfig } from "../lib/config.js"; 3 + import { createHomeRoutes } from "./home.js"; 4 + import { createBoardsRoutes } from "./boards.js"; 5 + import { createTopicsRoutes } from "./topics.js"; 6 + import { createLoginRoutes } from "./login.js"; 7 + import { createNewTopicRoutes } from "./new-topic.js"; 8 + import { createAuthRoutes } from "./auth.js"; 9 + 10 + const config = loadConfig(); 7 11 8 12 export const webRoutes = new Hono() 9 - .route("/", homeRoutes) 10 - .route("/", boardsRoutes) 11 - .route("/", topicsRoutes) 12 - .route("/", loginRoutes) 13 - .route("/", newTopicRoutes); 13 + .route("/", createHomeRoutes(config.appviewUrl)) 14 + .route("/", createBoardsRoutes(config.appviewUrl)) 15 + .route("/", createTopicsRoutes(config.appviewUrl)) 16 + .route("/", createLoginRoutes(config.appviewUrl)) 17 + .route("/", createNewTopicRoutes(config.appviewUrl)) 18 + .route("/", createAuthRoutes(config.appviewUrl));
+65 -10
apps/web/src/routes/login.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 3 import { PageHeader } from "../components/index.js"; 4 + import { getSession } from "../lib/session.js"; 4 5 5 - export const loginRoutes = new Hono().get("/login", (c) => 6 - c.html( 7 - <BaseLayout title="Login — atBB Forum"> 8 - <PageHeader 9 - title="Sign in" 10 - description="Sign in with your AT Protocol account." 11 - /> 12 - </BaseLayout> 13 - ) 14 - ); 6 + export function createLoginRoutes(appviewUrl: string) { 7 + return new Hono().get("/login", async (c) => { 8 + const auth = await getSession(appviewUrl, c.req.header("cookie")); 9 + 10 + // If already logged in, redirect to homepage 11 + if (auth.authenticated) { 12 + return c.redirect("/"); 13 + } 14 + 15 + const rawError = c.req.query("error"); 16 + // decodeURIComponent throws URIError on malformed percent-encoding (e.g. %ZZ) 17 + const error = rawError 18 + ? (() => { 19 + try { 20 + return decodeURIComponent(rawError); 21 + } catch { 22 + return rawError; 23 + } 24 + })() 25 + : undefined; 26 + 27 + return c.html( 28 + <BaseLayout title="Sign in — atBB Forum" auth={auth}> 29 + <PageHeader 30 + title="Sign in" 31 + description="Sign in with your AT Protocol account." 32 + /> 33 + <div class="login-form"> 34 + {error && ( 35 + <div class="login-form__error" role="alert"> 36 + {error} 37 + </div> 38 + )} 39 + <form 40 + action="/api/auth/login" 41 + method="get" 42 + class="login-form__form" 43 + > 44 + <label for="login-handle" class="login-form__label"> 45 + AT Protocol handle 46 + </label> 47 + <input 48 + type="text" 49 + id="login-handle" 50 + name="handle" 51 + placeholder="alice.bsky.social" 52 + class="login-form__input" 53 + required 54 + autocomplete="username" 55 + autofocus 56 + /> 57 + <p class="login-form__hint"> 58 + Use any AT Protocol handle (e.g. <code>alice.bsky.social</code>{" "} 59 + or a custom domain). You own your posts — they live on your PDS. 60 + </p> 61 + <button type="submit" class="login-form__submit"> 62 + Log in with AT Proto 63 + </button> 64 + </form> 65 + </div> 66 + </BaseLayout> 67 + ); 68 + }); 69 + }
+18 -7
apps/web/src/routes/new-topic.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 3 import { PageHeader } from "../components/index.js"; 4 + import { getSession } from "../lib/session.js"; 4 5 5 - export const newTopicRoutes = new Hono().get("/new-topic", (c) => 6 - c.html( 7 - <BaseLayout title="New Topic — atBB Forum"> 8 - <PageHeader title="New Topic" description="Compose a new topic." /> 9 - </BaseLayout> 10 - ) 11 - ); 6 + export function createNewTopicRoutes(appviewUrl: string) { 7 + return new Hono().get("/new-topic", async (c) => { 8 + const auth = await getSession(appviewUrl, c.req.header("cookie")); 9 + return c.html( 10 + <BaseLayout title="New Topic — atBB Forum" auth={auth}> 11 + <PageHeader title="New Topic" description="Compose a new topic." /> 12 + {auth.authenticated ? ( 13 + <p>Compose form will appear here.</p> 14 + ) : ( 15 + <p> 16 + <a href="/login">Log in</a> to create a topic. 17 + </p> 18 + )} 19 + </BaseLayout> 20 + ); 21 + }); 22 + }
+19 -8
apps/web/src/routes/topics.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 3 import { PageHeader, EmptyState } from "../components/index.js"; 4 + import { getSession } from "../lib/session.js"; 4 5 5 - export const topicsRoutes = new Hono().get("/topics/:id", (c) => 6 - c.html( 7 - <BaseLayout title="Topic — atBB Forum"> 8 - <PageHeader title="Topic" /> 9 - <EmptyState message="No replies yet." /> 10 - </BaseLayout> 11 - ) 12 - ); 6 + export function createTopicsRoutes(appviewUrl: string) { 7 + return new Hono().get("/topics/:id", async (c) => { 8 + const auth = await getSession(appviewUrl, c.req.header("cookie")); 9 + return c.html( 10 + <BaseLayout title="Topic — atBB Forum" auth={auth}> 11 + <PageHeader title="Topic" /> 12 + {auth.authenticated ? ( 13 + <p>Reply form will appear here.</p> 14 + ) : ( 15 + <p> 16 + <a href="/login">Log in</a> to reply. 17 + </p> 18 + )} 19 + <EmptyState message="No replies yet." /> 20 + </BaseLayout> 21 + ); 22 + }); 23 + }
+66 -4
devenv.nix
··· 8 8 9 9 packages = [ 10 10 pkgs.turbo 11 + pkgs.nginx 11 12 ]; 12 13 13 14 services.postgres = { ··· 25 26 ''; 26 27 }; 27 28 28 - processes = { 29 - appview.exec = "pnpm --filter @atbb/appview dev"; 30 - web.exec = "pnpm --filter @atbb/web dev"; 31 - }; 29 + processes = 30 + let 31 + # Inline nginx config using Nix store paths so it works in the Nix 32 + # environment (no /etc/nginx/mime.types). Mirrors production nginx.conf 33 + # but listens on port 8080 (no root required) and uses a tmp working dir. 34 + nginxConf = pkgs.writeText "atbb-nginx-dev.conf" '' 35 + pid /tmp/atbb-nginx-dev.pid; 36 + error_log /dev/stderr; 37 + 38 + events { 39 + worker_connections 1024; 40 + } 41 + 42 + http { 43 + include ${pkgs.nginx}/conf/mime.types; 44 + default_type application/octet-stream; 45 + 46 + access_log /dev/stdout; 47 + 48 + client_max_body_size 10M; 49 + proxy_connect_timeout 60s; 50 + proxy_send_timeout 60s; 51 + proxy_read_timeout 60s; 52 + 53 + server { 54 + listen 8080; 55 + 56 + # OAuth client metadata → appview 57 + # AT Protocol fetches {client_id}/.well-known/oauth-client-metadata 58 + # to validate the OAuth client. OAUTH_PUBLIC_URL should be set to 59 + # http://localhost:8080 so this route serves the correct document. 60 + location /.well-known/ { 61 + proxy_pass http://localhost:3000; 62 + proxy_set_header Host $host; 63 + proxy_set_header X-Real-IP $remote_addr; 64 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 65 + proxy_set_header X-Forwarded-Proto $scheme; 66 + } 67 + 68 + # API routes → appview 69 + location /api/ { 70 + proxy_pass http://localhost:3000; 71 + proxy_set_header Host $host; 72 + proxy_set_header X-Real-IP $remote_addr; 73 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 74 + proxy_set_header X-Forwarded-Proto $scheme; 75 + } 76 + 77 + # Web UI → web 78 + location / { 79 + proxy_pass http://localhost:3001; 80 + proxy_set_header Host $host; 81 + proxy_set_header X-Real-IP $remote_addr; 82 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 83 + proxy_set_header X-Forwarded-Proto $scheme; 84 + } 85 + } 86 + } 87 + ''; 88 + in 89 + { 90 + appview.exec = "pnpm --filter @atbb/appview dev"; 91 + web.exec = "pnpm --filter @atbb/web dev"; 92 + nginx.exec = "${pkgs.nginx}/bin/nginx -c ${nginxConf} -p /tmp/atbb-nginx-dev -g 'daemon off;'"; 93 + }; 32 94 }
+12
nginx.conf
··· 25 25 server { 26 26 listen 80; 27 27 28 + # OAuth client metadata → appview 29 + # AT Protocol fetches {client_id}/.well-known/oauth-client-metadata to 30 + # validate the OAuth client. AppView serves this document, so all 31 + # /.well-known/* requests must reach AppView, not the web UI. 32 + location /.well-known/ { 33 + proxy_pass http://localhost:3000; 34 + proxy_set_header Host $host; 35 + proxy_set_header X-Real-IP $remote_addr; 36 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 37 + proxy_set_header X-Forwarded-Proto $scheme; 38 + } 39 + 28 40 # API routes → appview 29 41 location /api/ { 30 42 proxy_pass http://localhost:3000;