로그인 상태를 확인하는 중입니다…

contextBio 내부 문서 · 전체관리자 · 앱관리자

관리자 로그인이 필요합니다

계정 페이지로 이동합니다. 로그인하면 보던 자리로 그대로 돌아옵니다.

로그인하러 가기

계정이 없으신가요? 같은 화면에서 가입할 수 있습니다.

접근 권한 없음

열 수 없습니다

로그인 계정:

연결 실패

확인하지 못했습니다

contextBio LABIS 구현

연구실·학과 운영 시스템의 구현 스펙 — Next.js·Postgres 배치, 통합계정 SSO 로 Auth.js 세션을 굽는 경로, 랩 스코프 가드, 시트·폴더 수집.

기준 2026-10-05 · main
79037d1

한눈에

학과와 여러 연구실을 위한 운영 관리 시스템(한국어 UI, 멀티랩). 과제현황·프로젝트·인사· 연구비 수지·성과·구매·장비·인벤토리 + LIMS(시료·실험), 그리고 주간보고·인사평가 같은 ERP 기본 업무.

항목 값
저장소 contextBio/LABIS (bare ContextBio/LABIS.git)
호스트 c1.sysmed.kr
운영 워크트리 ContextBio/LABIS — main, :3100, basePath /labis, DB labi, 로그 ~/labis-server.log
개발 워크트리 dev/LABIS — dev, :3101, basePath /labis-dev, DB labi_dev(운영 복제본), 로그 ~/labis-dev-server.log
백엔드 주소 c1.sysmed.kr/labis · 개발 c1.sysmed.kr/labis-dev
프론트 운영 contextbio.ai/labis · 개발 미러 dev-contextbio.web.app/labis (아래 참조)
스택 Next.js 15(App Router · Server Actions) · TypeScript · Tailwind CSS 4
DB PostgreSQL 17(S1 네이티브) + Prisma 6.19.3 — prisma/schema.prisma
로그인 contextBio 통합계정 ID 토큰 단일 — 자체 계정 없음
릴리즈 scripts/release.sh (빌드 포함)

README 의 "작업 디렉터리는 apps/LABIS 하나" 는 낡았다 — 지금은 다른 앱과 같은 bare + main/dev 이중 워크트리다. README 의 systemd 예시에 남아 있는 apps/LABIS 경로와 "끝나면 dev 로 복귀" 문구도 그렇다. 브랜치를 갈아타지 않는다.

화면과 백엔드가 둘로 나뉜다

층 실체
화면 frontpage/index.html — 한 장짜리 정적 SPA. 해시 라우팅(#/settings 등), 프레임워크·빌드 없음
REST /api/v1/* — Authorization: Bearer <통합계정 ID 토큰> (src/lib/apiGuard.ts)
기존 화면 Next.js SSR(src/app/(app)/…) — Auth.js 세션 쿠키. /enter?lab=N 으로 들어온다

SPA 는 사이트 저장소의 build_subapp.py 가 상단 줄·푸터·Firebase 브리지를 씌워 굽는다. 백엔드 주소는 cb-backend 메타로 받는다.

LABIS 는 이제 공개(운영) 앱이다. content.py 의 RELEASE_APPS 가 ("aurora", "pepdesigner", "labis") 이고, 운영 빌드(deploy.yml)가 contextBio/LABIS main 의 frontpage/ 를 구워 contextbio.ai/labis 에 싣는다. 백엔드는 운영 https://c1.sysmed.kr/labis (main 워크트리 :3100)다. Firebase main 타깃이 /labis/** 를 서빙하고 /LABIS·/LabIS·/labi 는 /labis 로 리다이렉트한다(firebase.json). 개발 사이트는 deploy-dev.yml 이 LABIS dev 브랜치의 frontpage/ 를 구워 dev-contextbio.web.app/labis 에 싣고, SUBAPP_BACKEND_LABIS=https://c1.sysmed.kr/labis-dev 로 dev 백엔드를 붙인다. BioWrit 만 개발 전용으로 남는다.

운영 백엔드(:3100)와 main 릴리즈가 곧 공개 화면이 붙는 대상이다(최근 release-20260929-1044). 2026-09-02 에 "dev 에만 배포" 지시가 있었지만 09-03 이후 릴리즈 태그가 이어진다 — 운영 릴리즈를 낼지는 그때그때 확인한다.

로그인 — 자체 계정이 없다

2026-08-31 에 자체 로그인을 전면 삭제했다(이메일+비밀번호·Google·MUSE c1 계정 경로 모두). 통과 수단은 contextBio 통합계정 ID 토큰 하나이고, 받는 자리는 둘이다.

SPA → /api/v1/*
   → apiGuard.ts            Bearer 토큰을 요청마다 검증(폐기·클레임 포함, 5분 캐시)
                            쿠키를 쓰지 않으므로 CSRF 표면이 없다

기존 화면(SSR) → /enter?lab=N → /login (#token= 릴레이)
   → /api/sso/contextbio    토큰 검증 후 Auth.js JWT 세션을 직접 굽는다(12시간)
  • 검증은 src/lib/contextbio.ts 가 한다. CONTEXTBIO_FIREBASE_API_KEY 가 비면 모든 토큰을 거부한다(fail closed).
  • 토큰은 프래그먼트(#token=)로 건넨다 — 서버 접근 로그·Referer 에 남지 않는다.
  • 비밀번호는 어디에도 없다. 계정 관리(비밀번호·프로필)는 contextBio 화면에서 한다.
  • 공개 가입이 없다 — 관리자가 초대한 이메일의 통합계정만 통과한다. 클레임 apps 가 있으면 labis 가 들어 있어야 한다(전체관리자는 예외).
  • 최초 접속의 /setup 은 학과관리자 레코드만 만든다. 로그인은 같은 이메일의 통합계정으로 한다.
  • CORS 허용 오리진은 코드에 고정돼 있다 — contextbio.ai·www.contextbio.ai· dev-contextbio.web.app.
  • MUSE(c1 서버 사용자 관리)는 별개 서비스다 — LABIS 의 로그인 수단이 아니다.

조직과 권한

학과(전역) → 연구실 × N → 구성원(Membership). 한 사용자가 여러 랩에 소속될 수 있다.

역할은 학과관리자(User.isDeptAdmin) / PI / 랩매니저 / 연구원(LabRole = PI·LAB_MANAGER·MEMBER)이다. 모든 페이지·액션이 서버 가드 requireLab 로 활성 랩(사이드바 전환기) + 역할을, REST 는 apiMenuAllowed 로 같은 판정을 한다. 관리 행위는 감사 로그에 남는다. 실장(isDirector)은 멤버십의 별도 표지이며 PI·학과관리자만 지정한다.

그 위에 대시보드 카드 단위 권한이 얹힌다 — 좁히기 전용 층이다. 카드는 과제현황·프로젝트·인사·구매·자산·성과·LIMS 이고 구성원마다 불가·보기·편집 을 정한다.

  • 2026-09-27 부터 연구원은 기본 불가(fail closed) 다. 설정이 없으면 프로젝트만 보기·편집이고 나머지는 막힌다. 대시보드와 주간보고 제출은 늘 열린다.
  • 랩매니저는 기본 편집, PI·학과관리자는 조정 대상이 아니다(항상 전체 편집).
  • 권한은 역할을 넘지 못한다 — 편집이어도 역할이 모자라면 여전히 막힌다.
  • 설정 화면은 관리자 설정(#/settings)의 "대시보드 카드별 접근 권한"(PI 이상).
src/lib/menus.ts   메뉴 목록·카드 묶음(CARD_GROUPS) 단일 원본
src/lib/perm.ts    좁히기 판정 · defaultLevel
requireLab(minRole, menu, need)   페이지·액션 가드
apiMenuAllowed     REST 쪽 같은 판정

사이트 앱관리 화면(/labis/admin)의 '앱관리자' 는 학과관리자다. 연구실 단위 운영자(PI·랩매니저)는 자기 랩의 관리자 설정을 쓰고 그 화면에는 오지 않는다. GET /api/management/users 는 앱관리자에게, 학과관리자 지정·해제(PATCH)는 통합계정 전체관리자에게만 열린다. LABIS 에는 잡 개념이 없어 jobs·kill 액션이 없다.

연구실 수와 폐쇄

연구실은 삭제하지 않고 폐쇄한다. 폐쇄된 랩은 입장 목록·랩 전환기에서 감추고 관리자 설정에서만 보인다(다시 운영으로 돌리는 길이 있어야 한다).

운영·휴면 연구실은 최대 5개다(src/lib/labLimit.ts 의 MAX_LABS). 개설 경로가 관리자 설정·REST(/api/v1/labs)·입장 화면의 셀프 개설 셋이라 상한을 한 곳에 두고 셋 모두 서버에서 막는다. 폐쇄된 랩은 세지 않는다 — 자리를 비우는 유일한 길이 폐쇄다.

코드 맵

frontpage/index.html   SPA (화면 전부)
src/lib/apiGuard.ts    /api/v1 Bearer 가드 · CORS
src/lib/contextbio.ts  통합계정 토큰 검증
src/lib/auth.ts        Auth.js 설정 (SSR 세션)
src/lib/guard.ts       requireUser · requireDeptAdmin · requireLab (감사 로그는 audit.ts)
src/lib/managementApi.ts  사이트 앱관리 규약(session·users)
src/lib/queries.ts     조회 (랩 스코프)
src/lib/actions.ts     도메인 CRUD (가드 적용)
src/lib/orgActions.ts  랩·구성원·초대 관리
src/lib/google.ts      Sheets API(JWT) · 공개 CSV · 랩별 설정
src/lib/sheetSync.ts   시트 탭 ↔ DB 매핑 (syncRows)
src/lib/grantSync.ts   과제현황 전용 동기화
src/lib/weeklyDocs.ts  주간보고 파일 보관
src/app/api/v1/…       SPA 용 REST
src/app/(app)/…        기존 화면 (인증 필요)
scripts/ingest-agent.ts   폴더 수집 에이전트
scripts/sheet-agent.ts    구글시트 수집 에이전트
scripts/release.sh        dev → main 머지 · 빌드 · :3100 재기동

scripts/ 는 server-only 모듈을 타면 안 된다. tsx 로 Next 런타임 밖에서 돌아 guard.ts(쿠키·리다이렉트)를 import 하는 순간 죽는다. 감사 로그를 audit.ts 로 떼어낸 이유다.

SPA 에 새 화면·새 조회를 넣으면 fresh(list(...)) / fresh(req(...)) 로 감싼다. 늦게 온 응답이 사라진 DOM 을 건드리면 그 오류가 새 화면의 #err 에 오배송돼, 멀쩡한 화면이 "불러오지 못했습니다" 로 보인다.

시트·폴더로 데이터를 넣는 두 길

둘의 매핑 규칙이 같다(같은 SPECS) — 탭 이름이 항목 이름(과제·참여연구원·마일스톤· 예산집행·시료·실험·장비·인벤토리·휴가 등)과 일치하면 반영된다. 인원 탭은 계정과 결합되어 건너뛴다(내보내기 전용).

전체 교체를 하지 않는다. 예전에는 랩의 행을 deleteMany 로 지우고 다시 넣어 사람이 화면에서 넣은 행이 사라지고 행 id 가 매번 바뀌었다. 지금은 syncRows() 가 코드·장비명· sourceKey(행 내용에서 만든 자연키)로 갱신·추가하고, 시트에서 사라진 시트 기원 행만 지운다. 화면에서 넣은 행(sourceKey 가 빈 행)은 남는다. 새 항목을 붙일 때 deleteMany 를 쓰지 않는다. 과제현황(grant_participations)은 더 보수적이라 사라진 행도 지우지 않고 sourceMissing 으로만 표시한다.

한 항목을 시트와 폴더 파일로 동시에 관리하지 않는다 — 서로를 '사라진 행' 으로 보고 지운다.

  • 구글시트 연동 — 관리자 설정 안의 구획. 항목별 시트 주소를 저장하면 즉시 그 항목 DB 로 반영한다. 워크시트는 URL 의 #gid= > 항목 이름과 같은 탭 > 시트가 하나뿐이면 그 시트 순으로 고른다. 항목별 주소가 없으면 랩 통합 스프레드시트를 쓴다. 시트 가져오기는 명부의 이름으로 사람을 찾으므로 시트의 이름이 같아야 반영된다.
  • 구글시트 수집 에이전트 — 화면에 등록한 주소를 주기적으로 다시 읽는다. 내용 지문이 그대로면 DB 를 건드리지 않고, 폐쇄한 연구실은 건너뛴다.
  • 폴더 수집 에이전트 — 지정 폴더의 .xlsx(워크시트 이름) / .csv(파일 이름)를 읽는다. 변경된 파일만 재처리하고(mtime 추적, --force 로 전체), 결과는 감사 로그에 남는다.
npm run sheet                          # 연결된 모든 랩·항목 1회 (변경분만)
npm run sheet:watch -- --interval 300  # 주기 실행 (초)
npm run ingest -- --dir <폴더> --lab 2          # 폴더 1회 반영
npm run ingest -- --add-source <폴더> 2 "A랩"    # 폴더↔랩 매핑 등록
npm run ingest:watch                             # 감시 모드 (기본 30초)

시트를 읽으려면 서비스 계정 키가 필요하다(GOOGLE_SERVICE_ACCOUNT_FILE, 기본 data/service-account.json). 키는 저장소에 두지 않는다 — 히스토리에서 지운 이력이 있고 .gitignore 로 막혀 있다. 대상 시트·드라이브 폴더는 서비스 계정에 공유돼 있어야 한다.

주간보고 파일은 랩별 제출 폴더 LABIS_WEEKLY_DIR_<labId>(c1 에 마운트된 구글 드라이브)가 있으면 그 아래 <주 월요일>/ 에, 없으면 LABIS_UPLOAD_DIR(기본 data/uploads)/lab-<id>/weekly/<주> 에 둔다. 기존 파일은 덮어쓰지 않는다.

실행과 배포

export PATH=/opt/node/bin:$PATH   # Node 24 — 셸 기본 node 는 v12 라 실패한다
npm run dev                       # 개발 :3101 (.env.development → labi_dev, basePath /labis-dev, .next-dev)
./scripts/release.sh --dry-run    # 나갈 커밋만
./scripts/release.sh              # dev 푸시 → main 머지 + 태그 → npm ci·build → :3100 재기동

systemd 유닛이 없다 — 운영은 release.sh 가 nohup npm run start 로, 개발은 npm run dev 를 손으로 띄운다. 개발 서버는 distDir=.next-dev 라 운영 빌드(.next)를 덮지 않는다.

release.sh 는 dev·운영 워크트리가 모두 깨끗해야 돈다(.claude/ 는 제외). 서버를 먼저 내린다 — 구동 중에 npm ci 가 node_modules 를 지우다 ENOTEMPTY 로 죽기 때문이고, 내려간 동안(수 분)은 계획된 중단이다. fuser 가 NFS 경로를 훑다 멈춘 적이 있어 시간 제한을 두고 포트를 쥔 프로세스를 직접 내린다(2026-09-27). 빌드가 고쳐 쓰는 next-env.d.ts·tsconfig.json 은 끝나고 원복한다.

.env(운영)·.env.development(개발) 필수값은 DATABASE_URL(Postgres) · AUTH_SECRET · APP_URL · CONTEXTBIO_FIREBASE_API_KEY. 값은 워크트리별로 분리돼 있고 커밋하지 않는다. 스키마를 바꿀 때는 dev 에서 npm run db:migrate 로 마이그레이션을 만들고 릴리즈 후 npm run db:deploy.

  • 날짜는 YYYY-MM-DD 문자열로 저장한다 (date input·시트 연동과 일치).
  • 워크트리의 파일 권한(mode) 변경이 머지를 막으면 git -c core.fileMode=false <명령> 으로 통과시킨다 — 내용은 0줄 바뀐 노이즈다.
  • 화면 문구가 배포 후에만 이상하면 사이트 빌더를 의심한다. build_subapp.py 의 경로 재작성이 스크립트 문자열까지 건드려 3/10개 가 3/labis/10개 로 나온 적이 있다 (2026-09-05). 저장소 파일과 라이브 HTML 을 diff 하면 드러난다.