<!-- canonical: https://docs.axelabs.ai/services/layer -->
<!-- source: content/services/layer.mdx -->

---
title: Layer
description: 문서 공유·열람 추적 — PDF 업로드 → 공유 링크 → 무로그인 웹뷰어 → 페이지별 체류 분석. IC 메모·LP IR·포트폴리오 보고서 발송 추적이 1차 용도.
---

# Layer

**한 줄 소개**: 보낸 문서가 어떻게 읽히는지 보이게 하는 서비스. PDF 를 올리면 공유 링크가 생기고, 받은 사람이 로그인 없이 웹 뷰어로 읽는 동안 페이지별 체류·완독률·리드 정보를 수집한다 (featpaper/DocSend 류, [D-layer-1](/ops/decisions)).

## 기술 스택

| 항목 | 값 |
|---|---|
| 언어 | Python 3.12 |
| 프레임워크 | FastAPI (async) + Jinja2 + vanilla JS (빌드 도구 없음) |
| DB | PostgreSQL 16 + SQLAlchemy 2 async + Alembic |
| 문서 처리 (v2, D-layer-4) | **HTML 네이티브** — nh3 sanitize + 헤딩 자동 섹션추출(`htmlproc`). PDF 래스터(PyMuPDF) 폐기, PDF 는 `@media print` 출력 기능만 |
| 디자인 | @axe/ui vendor v0.5.0 (build-time 번들) + 폰트 self-host (CDN 0) |
| 배포 | docker compose blue/green + Caddy proxy (hive 패턴) |
| repo | `github.com/axelabs-ai/layer` (canonical `/Users/axe/layer`) |

## 포트 (42xx)

| 포트 | 용도 |
|---|---|
| 4200 | PostgreSQL 16 (layer-postgres) |
| 4210 | app blue (active) |
| 4211 | app green (passive) |
| 4212 | axe-layer-proxy (Caddy, blue/green selector, 127.0.0.1 바인딩) |

## URL 컨벤션 — 듀얼 마운트 (라이브, [D-layer-2](/ops/decisions))

[B-platform-domain-scoping](/ops/backlog) 의 2축 규칙을 그대로 적용. **같은 앱이 두 곳에 동시 노출** (gate 선례 미러링):

| URL | 의미 | root_path |
|---|---|---|
| `https://layer.axelabs.ai` | **전역 서비스 웹사이트** (익명 랜딩 + 로그인) | `""` |
| `https://axe.axelabs.ai/layer` | **axe 내부 문서 회람** | `/layer` |

- cloudflared 가 path prefix 를 보존 → **앱이 `scope["path"]` 로 prefix 자체 감지** (`MountPrefixMiddleware`, 외부 헤더 비신뢰). Caddy 는 strip 안 함 (gate 패턴).
- 공유 뷰어(무로그인): `/v/{slug}` · 페이지 이미지 `/v/{slug}/p/{n}.jpg` · 트래킹 `/v/{slug}/events` (각 마운트의 base 로 `public_base(request)` 가 절대 URL 생성).
- 헬스: `/health/ready` (DB 포함) · `/health/live`
- 로컬: `http://127.0.0.1:4212/` (proxy) · blue `:4210` / green `:4211`.

## 도메인 모델

**v2 (HTML 네이티브, D-layer-4):** `users` → `documents` → `document_versions`(링크 유지 버전 교체) → `sections`(헤딩 anchor + level + title). 공유 = `links`(slug, `access_mode` open/gated/allowlist × `identify_policy` email/phone/either/both, `revoked_at`) + `link_allowlist`(email/phone/domain). 추적 = `visitors`(email/phone 식별) → `visits`(visit_token, `max_scroll_pct`) → `section_dwell`(섹션별 ms 가산) + `click_events`. (구 `pages`/`page_dwell` 폐기 — alembic 0002)

**엔게이지먼트 등급**: 방문이 0.5초 이상 본 페이지 비율 기준 HOT ≥70% / WARM ≥50% / COLD 미만 (total 0.5s 미만 방문 제외 — featpaper 벤치마크 호환).

## 환경 변수 / 비밀

`customers.yaml services.layer.secrets[]` 등재 (vault 캡처 = 운영자 raw-bw 1회 필요):

| env | vault | 용도 |
|---|---|---|
| `LAYER_DB_PASSWORD` | `layer/axe/db-password` | postgres |
| `LAYER_SECRET_KEY` | `layer/axe/secret-key` | 세션 서명·ip 해시 |
| `LAYER_ADMIN_PASSWORD` | `layer/axe/admin-password` | 초기 admin 시드 (`LAYER_ADMIN_EMAIL` 과 페어) |

비밀 아닌 설정: `LAYER_DB_HOST/PORT/USER/NAME`, `LAYER_DATA_DIR`(기본 /data 볼륨), `LAYER_PUBLIC_BASE_URL`(no-Host 폴백), `LAYER_MOUNTED_PREFIX`(기본 `/layer` — axe 마운트 prefix).

### SSO (Microsoft Entra ID, [D-layer-3](/ops/decisions))

| env | vault / 값 | 용도 |
|---|---|---|
| `LAYER_OIDC_CLIENT_ID` | `0e034eee-81a8-4e5f-a61b-1ff24ac7a5b0` (비밀 아님) | Entra `Layer Web` 앱 |
| `LAYER_OIDC_CLIENT_SECRET` | `layer/axe/oidc-client-secret` | confidential client secret |
| `LAYER_OIDC_TENANT_ID` | `122fb574-…` (비밀 아님) | AXE 테넌트 |
| `LAYER_OIDC_ALLOWED_DOMAIN` | `axellc.com` | email 도메인 화이트리스트(authZ 경계) |

세 OIDC 값이 모두 있을 때만 SSO 활성(`oidc_enabled`); 없으면 password 로그인만(로컬/테스트). 로그인 = `/auth/sso/login` → Entra → `/auth/sso/callback`. 자세한 검증/인가/흐름 보안은 [auth.mdx](/architecture/auth) "Layer Web app 설정". 부트스트랩 운영자 계정은 `sso:` 타입, **break-glass** `breakglass@layer.local`(password).

## 상태 (2026-06-13)

- ✅ MVP — 업로드→링크→뷰어→추적→분석 핵심 루프 ([B-layer-mvp](/ops/backlog)), tests 41 green.
- ✅ **라이브 노출** — `layer.axelabs.ai`(전역) + `axe.axelabs.ai/layer`(axe 회람) 듀얼 마운트, HTTPS e2e(로그인→대시보드, 양쪽 마운트 prefix·static·Set-Cookie 스코프·스푸핑 방어) 실측 ([D-layer-2](/ops/decisions)).
- ✅ **SSO 로그인** — Microsoft Entra ID OIDC(authorization code flow, FastAPI self-impl), 적대 리뷰 7건 반영, admin-consent 완료. 라이브 authorize redirect 양쪽 마운트 정확일치 실측, tests 63 green ([D-layer-3](/ops/decisions)). 브라우저 왕복은 사용자 실로그인 시 완결.
- ✅ **v2 HTML 네이티브 전환 ([D-layer-4](/ops/decisions))** — PDF 래스터 폐기 → HTML 반응형 뷰어(@axe/ui, 목차 스크롤스파이+읽음 dot, 햄버거) + sandbox iframe(allow-scripts, allow-same-origin 제외)+엄격 CSP(script-src nonce 1개)+nonce 추적→postMessage→부모 인증 POST. 이메일/전화 식별+allowlist, 섹션/스크롤 인게이지먼트(문서목록 행 노출). 3-wave 빌드(기초→라우터/UI→마무리), 적대 보안검토(data: URL XSS 차단)·**실 Chrome e2e**(추적 실행 검증), **tests 114 green**. 라이브 DB 0002 마이그레이션 + 컨테이너 재배포 + 클린 슬레이트 커토버 완료. 설계 = `~/layer/ctx/layer-v2-redesign-proposal.md`, 계약 = `~/layer/CONTRACT_V2.md`.
- ⏳ 잔여 ([B-layer-platform-integration](/ops/backlog)): axe-cli `SHIP_SERVICES` 등록(현재 `axe ship layer` 불가 — 수동 `docker compose --env-file .env.local up -d --build`), **vault 캡처 4건**(DB/secret/admin + OIDC client secret — 현재 `.env.local` 에만, 운영자 raw-bw 1회).
- Phase-2 defer: 멀티테넌트/과금, 영상·임베드 오버레이(Motion), 메일머지 링크, Zapier/플러그인, AI 요약·인용 최적화

## 함정

| # | 함정 | 증상 | 회피 |
|---|---|---|---|
| 1 | 40xx/41xx 가 비어 보임 (CLAUDE.md 포트표 미갱신) | 신규 서비스 포트 충돌 | 40xx=index, 41xx=gate 점유. 배정 전 `lsof` 실측 (layer=42xx) |
| 2 | axelabs-docs repo 에 origin 원격 없음 | `axe work docs <slug>` 가 fetch 실패 | docs 는 CLAUDE.md 빠른 갱신 절차대로 공유 트리 직접 편집 + 본인 파일만 선택 커밋 |
