feat(web): serve the web app under /app and send every billing link there (WS-B)

apps/web was never deployed, so /billing on the public domain returned the
landing page and d3ro.dev (desktop "upgrade") did not resolve.

- apps/web runs with basePath /app and output standalone; /download and
  /releases redirect to the site's #download. A Dockerfile and a d3ro-web
  compose service (port 3002) deploy it to the NAS with the other images.
- The site bridge worker forwards /app/* to WEB_APP_ORIGIN (the tunnel host)
  and rewrites upstream redirects; everything else still goes to Pages.
  With no origin configured /app answers 503 instead of the landing page.
- Desktop upgrade, desktop Stripe return, mobile subscription management,
  the web checkout/portal returns and the site all use billingUrl(); the
  return query is success=1 / canceled=1, which the billing page reads.
  The billing page highlights ?tier=pro|pro_plus, and signing in from a
  billing link returns to the same plan.
- auth/callback pins the redirect origin in production and rejects
  protocol-relative next= values (open redirect).
- Mobile legal links use SITE_URLS (fixes the missing slash on /terms).
- Compose drops the unused NEXT_PUBLIC_API_URL and the dead wwwroot legal
  mounts; deploy scripts add the web image and the SUPABASE_* values the NAS
  compose already required; .dockerignore keeps app .env files out of images.
- Supabase auth redirects allow /app/** (remote dashboard must match).

Policy: docs/REFACTOR_POLICY.md Wave 3, W3-3 and W3-4.
This commit is contained in:
Yun Chan 2026-09-26 15:48:30 +09:00
parent 88f24d84a1
commit b6fe588a7c
30 changed files with 493 additions and 95 deletions

18
apps/web/src/proxy.ts Normal file
View file

@ -0,0 +1,18 @@
// apps/web/src/proxy.ts
// 외부 진입점(데스크톱·모바일·사이트가 여는 /app/billing?tier=…)으로 들어온 요청의 경로를
// (app) 레이아웃에 넘겨, 로그인이 필요할 때 로그인 뒤 같은 곳으로 돌아오게 한다.
import { NextResponse, type NextRequest } from 'next/server'
import { RETURN_PATH_HEADER } from '@/lib/web-app-url'
export function proxy(request: NextRequest): NextResponse {
const headers = new Headers(request.headers)
// nextUrl.pathname 은 basePath('/app')를 뺀 앱 경로다.
headers.set(RETURN_PATH_HEADER, `${request.nextUrl.pathname}${request.nextUrl.search}`)
return NextResponse.next({ request: { headers } })
}
export const config = {
// basePath 기준 경로. 외부에서 직접 여는 결제 진입점만 대상이다.
matcher: ['/billing']
}