Since 2026-09-19 the site bridge worker served every path from Pages, so the API server on the NAS had no public route: admin login (API_SERVER_URL) and stt-proxy (D3RO_API_URL) called https://d3ro.chanpaca.net/api/... and got the landing page's 405. - The worker passes /app, /api and /health through to the domain origin (the kd-nas tunnel) and serves everything else from Pages. - The kd-nas tunnel sends path ^/app on d3ro.chanpaca.net to the web app (NAS 3002); other paths keep going to the API (NAS 5050). No extra hostname or DNS record is needed, so WEB_APP_ORIGIN is removed. - API_PATH_PREFIXES joins WEB_APP_BASE_PATH in packages/core/src/web-urls.ts (contract regenerated). Verified live: /app/login 200, /health and /api/health 200, API login 401 for an unknown account (was 405), landing/legal/404 unchanged, git/sso/admin hosts unaffected.
140 lines
6.2 KiB
TypeScript
140 lines
6.2 KiB
TypeScript
// 생성 파일 — 직접 수정 금지.
|
|
// 정본: packages/core/src/plan-catalog.ts, packages/core/src/web-urls.ts
|
|
// 갱신: npm run contract:sync / 검사: npm run contract:check
|
|
// Deno Edge Function은 packages/core를 번들할 수 없어서 정본 소스를 그대로 복사한다.
|
|
|
|
// ── 원본: packages/core/src/plan-catalog.ts ───────────────────────────
|
|
|
|
// packages/core/src/plan-catalog.ts
|
|
// 요금제 가격 SSOT — 데스크톱·웹·관리자·모바일·랜딩 사이트가 모두 여기서 읽는다.
|
|
//
|
|
// Supabase Edge Function(Deno)은 저장소의 이 경로를 번들할 수 없으므로
|
|
// `npm run contract:sync`가 server/supabase/functions/_shared/core-contract.generated.ts 로
|
|
// 값을 복사하고, `npm run contract:check`가 CI에서 어긋남을 막는다.
|
|
// 그래서 이 파일은 import 없이 순수 값만 둔다.
|
|
|
|
export type PaidPlanTier = 'pro' | 'pro_plus'
|
|
export type PlanTier = 'free' | PaidPlanTier
|
|
|
|
/**
|
|
* 월 요금(원). Payple 청구 금액이며, 기존 구독자도 다음 갱신부터 이 값으로 청구된다.
|
|
* Stripe(USD)·Google Play 가격은 각 콘솔에서 따로 관리한다.
|
|
*/
|
|
export const PLAN_PRICE_KRW: Readonly<Record<PlanTier, number>> = {
|
|
free: 0,
|
|
pro: 2900,
|
|
pro_plus: 8900,
|
|
}
|
|
|
|
// ── 클라우드 사용량 한도 ─────────────────────────────────
|
|
// 서버(Deno `_shared/quota.ts`)가 실제로 집행하고, 데스크톱·사이트는 표시만 한다.
|
|
// Free 는 주간, 나머지는 일간. limit: -1=무제한, 0=사용 불가, 양수=한도.
|
|
|
|
/** 사용량 한도를 갖는 티어. 결제 티어 외에 팀·엔터프라이즈 계약 티어를 포함한다. */
|
|
export type PlanQuotaTier = PlanTier | 'team' | 'enterprise'
|
|
|
|
/** 쿼터 추적 키 — 서버 daily_usage.feature 값과 같다. */
|
|
export type PlanQuotaFeature =
|
|
| 'stt_transcribe'
|
|
| 'llm_haiku'
|
|
| 'llm_sonnet'
|
|
| 'llm_opus'
|
|
| 'realtime_session'
|
|
|
|
export type PlanQuotaPeriod = 'daily' | 'weekly'
|
|
|
|
export interface PlanQuota {
|
|
/** -1=무제한, 0=사용 불가, 양수=한도 */
|
|
readonly limit: number
|
|
readonly period: PlanQuotaPeriod
|
|
}
|
|
|
|
export const PLAN_QUOTA: Readonly<Record<PlanQuotaTier, Readonly<Record<PlanQuotaFeature, PlanQuota>>>> = {
|
|
free: {
|
|
stt_transcribe: { limit: 250, period: 'weekly' },
|
|
llm_haiku: { limit: 250, period: 'weekly' },
|
|
llm_sonnet: { limit: 0, period: 'daily' },
|
|
llm_opus: { limit: 0, period: 'daily' },
|
|
realtime_session: { limit: 0, period: 'daily' },
|
|
},
|
|
pro: {
|
|
stt_transcribe: { limit: -1, period: 'daily' },
|
|
llm_haiku: { limit: 1500, period: 'daily' },
|
|
llm_sonnet: { limit: 300, period: 'daily' },
|
|
llm_opus: { limit: 50, period: 'daily' },
|
|
// 세션 수 기준 (~$0.016/분 mini — 세션당 평균 수 분 가정)
|
|
realtime_session: { limit: 30, period: 'daily' },
|
|
},
|
|
pro_plus: {
|
|
stt_transcribe: { limit: -1, period: 'daily' },
|
|
llm_haiku: { limit: -1, period: 'daily' },
|
|
llm_sonnet: { limit: 1500, period: 'daily' },
|
|
llm_opus: { limit: 300, period: 'daily' },
|
|
realtime_session: { limit: 120, period: 'daily' },
|
|
},
|
|
team: {
|
|
stt_transcribe: { limit: -1, period: 'daily' },
|
|
llm_haiku: { limit: -1, period: 'daily' },
|
|
llm_sonnet: { limit: 3000, period: 'daily' },
|
|
llm_opus: { limit: 600, period: 'daily' },
|
|
realtime_session: { limit: 300, period: 'daily' },
|
|
},
|
|
enterprise: {
|
|
stt_transcribe: { limit: -1, period: 'daily' },
|
|
llm_haiku: { limit: -1, period: 'daily' },
|
|
llm_sonnet: { limit: -1, period: 'daily' },
|
|
llm_opus: { limit: -1, period: 'daily' },
|
|
realtime_session: { limit: -1, period: 'daily' },
|
|
},
|
|
}
|
|
|
|
// ── 원본: packages/core/src/web-urls.ts ───────────────────────────────
|
|
|
|
// packages/core/src/web-urls.ts
|
|
// 공개 웹 주소 SSOT — 결제·법률·다운로드·초대 링크는 모두 여기서 만든다.
|
|
//
|
|
// 한 도메인 아래 세 서비스가 있다.
|
|
// / 랜딩 사이트(site/, Cloudflare Pages) — 다운로드, 법률 문서, 초대 수락
|
|
// /app/... 웹앱(apps/web, Next basePath '/app') — 로그인, 결제, 대시보드
|
|
// /api, /health API 서버(apps/api-server) — 관리자 인증, STT 중계
|
|
// 사이트 브리지 워커(server/cloudflare-site-bridge)가 /app·/api 요청을 도메인 원본
|
|
// (Cloudflare Tunnel kd-nas → NAS)으로 흘려보내고, 나머지는 Pages로 보낸다.
|
|
// 터널 ingress: d3ro.chanpaca.net path ^/app → NAS 3002, 그 밖 → NAS 5050.
|
|
// Deno 쪽 사본은 plan-catalog.ts 와 같은 방식으로 생성된다. 이 파일도 순수 값만 둔다.
|
|
|
|
|
|
export const PUBLIC_SITE_ORIGIN = 'https://d3ro.chanpaca.net'
|
|
|
|
/** apps/web 의 Next basePath. next.config 와 브리지 워커 라우팅이 이 값을 따른다. */
|
|
export const WEB_APP_BASE_PATH = '/app'
|
|
|
|
export const WEB_APP_URL = `${PUBLIC_SITE_ORIGIN}${WEB_APP_BASE_PATH}`
|
|
|
|
/** API 서버(apps/api-server)가 받는 경로. 관리자 로그인·stt-proxy가 이 도메인으로 부른다. */
|
|
export const API_PATH_PREFIXES = ['/api', '/health'] as const
|
|
|
|
export const SITE_URLS = {
|
|
home: `${PUBLIC_SITE_ORIGIN}/`,
|
|
download: `${PUBLIC_SITE_ORIGIN}/#download`,
|
|
privacy: `${PUBLIC_SITE_ORIGIN}/privacy/`,
|
|
terms: `${PUBLIC_SITE_ORIGIN}/terms/`,
|
|
deleteAccount: `${PUBLIC_SITE_ORIGIN}/delete-account/`,
|
|
acceptInvite: `${PUBLIC_SITE_ORIGIN}/accept-invite/`,
|
|
} as const
|
|
|
|
/** 결제 결과로 돌아올 때 붙는 쿼리. apps/web 결제 페이지가 읽는 이름과 같다. */
|
|
export type BillingReturn = 'success' | 'canceled'
|
|
|
|
/**
|
|
* 웹 결제 페이지 URL.
|
|
* - `tier`: 고를 요금제를 미리 선택한다.
|
|
* - `result`: 외부 결제(Stripe 등)에서 돌아올 때의 결과.
|
|
*/
|
|
export function billingUrl(options: { tier?: PaidPlanTier; result?: BillingReturn } = {}): string {
|
|
const params = new URLSearchParams()
|
|
if (options.tier) params.set('tier', options.tier)
|
|
if (options.result === 'success') params.set('success', '1')
|
|
if (options.result === 'canceled') params.set('canceled', '1')
|
|
const query = params.toString()
|
|
return `${WEB_APP_URL}/billing${query ? `?${query}` : ''}`
|
|
}
|