From 4d32208b818794bfc8ad906ae04e11938222f32c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yunsup=20Sim=28=EC=8B=AC=EC=9C=A4=EC=84=AD=29?= Date: Sat, 14 Nov 2026 11:05:13 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9A=B4=EC=98=81=20Pi=20=EB=B7=B0?= =?UTF-8?q?=EC=96=B4=EB=A5=BC=20=EB=B3=B4=EC=A1=B4=ED=95=B4=20Rocky=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=88=ED=84=B0=20=EC=97=B0=EA=B2=B0=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(attendance): 운영 Pi 뷰어를 보존해 모니터 연결 플랫폼 Access와 기존 조회 스냅샷 계약을 유지합니다. 원장 KV 쓰기 없이 수신 요약과 Rocky 헬스를 독립 조회합니다. 폐기된 Worker 쓰기 경로와 미사용 화면·로직을 제거합니다. --- README.md | 2 +- apps/attendance/README.md | 113 +- apps/attendance/src/automation.ts | 3 +- apps/attendance/src/dashboard.html | 4032 +++++++++++++++++++++ apps/attendance/src/dashboard.ts | 959 ----- apps/attendance/src/index.ts | 280 +- apps/attendance/src/logic.ts | 137 - apps/attendance/src/viewer.ts | 340 ++ apps/attendance/test/automation.test.mjs | 52 +- apps/attendance/test/logic.test.mjs | 89 - apps/attendance/test/viewer.test.mjs | 87 + apps/attendance/worker-configuration.d.ts | 8 +- apps/attendance/wrangler.jsonc | 9 +- 13 files changed, 4549 insertions(+), 1562 deletions(-) create mode 100644 apps/attendance/src/dashboard.html delete mode 100644 apps/attendance/src/dashboard.ts delete mode 100644 apps/attendance/src/logic.ts create mode 100644 apps/attendance/src/viewer.ts delete mode 100644 apps/attendance/test/logic.test.mjs create mode 100644 apps/attendance/test/viewer.test.mjs diff --git a/README.md b/README.md index 6437fa1..d837e6a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ apps/ web/ TanStack Start 프론트엔드·SSR Worker api/ Hono 업무 API Worker (레거시, 기본 비활성) b-bot/ Slack 봇 Worker - attendance/ 출근부: 카드 태그 기록 Worker·대시보드, 라즈베리파이 리더 + attendance/ 비공개 Pi 관측 스냅샷 뷰어·오류 수신/Rocky 읽기 전용 모니터 error-automation/ 오류 웹훅 수신과 자동 수정 작업 시작 packages/ telemetry/ 공통 민감정보 마스킹 diff --git a/apps/attendance/README.md b/apps/attendance/README.md index 0e6d9c2..8e728c2 100644 --- a/apps/attendance/README.md +++ b/apps/attendance/README.md @@ -1,88 +1,65 @@ -# 출근부 (attendance) +# 비공개 출근부 스냅샷·자동화 모니터 -인터널 출입 카드를 리더로 태그하면 입실/퇴실을 기록하고, 대시보드에 현재 인원과 체류 시간을 보여 주는 Worker입니다. 저장소는 Workers KV입니다. +`bcsd-attendance`는 Pi가 게시한 출입 현황의 관측 시점 스냅샷을 읽는 Worker입니다. 출입 원장을 저장하거나 회원·태그를 수정하지 않습니다. 오류 수신 요약과 Rocky 헬스는 별도 Worker service binding으로 조회합니다. -```text -RC522(SPI) 또는 USB HID 리더 ──▶ 라즈베리파이 3B(pi/reader.py) ──POST /api/taps──▶ Worker + KV ──▶ 대시보드(/) -``` - -## 동작 규칙 - -- 태그는 토글입니다. 열린 세션이 없으면 입실, 있으면 퇴실입니다. -- 같은 회원의 직전 이벤트 후 10초 안의 태그(카드 중복 인식)와, 직전 이벤트보다 이른 재전송은 무시합니다. -- 퇴근 태그가 없는 세션은 입실 후 6시간(`MAX_SESSION_MS`)이 지나면 입실 중으로 보지 않습니다. 같은 회원의 다음 태그가 6시간 시점에 그 세션을 자동 종료(`autoClosed`)하고 새 입실을 만듭니다. 이런 세션은 실제 체류 시간을 알 수 없으므로 오늘·이번 주 시간 합계에 넣지 않고, 대시보드에 "퇴근 미기록"으로 건수를 표시합니다. -- 오늘·이번 주(월요일 시작) 경계는 KST입니다. 자정을 걸친 세션은 날짜별로 나눠 합산합니다. -- 등록되지 않은 카드를 태그하면 `unknown:`에 쌓이고, 대시보드의 "미등록 카드"에서 이름을 입력해 등록합니다. 같은 카드로 다시 등록하면 이름만 바뀝니다. 등록하지 않은 카드는 7일 뒤 자동 삭제됩니다. - -## 저장 구조 (KV) - -데이터는 모두 키 메타데이터에 두어 대시보드가 `list` 호출만으로 읽습니다. +## 인증과 읽기 전용 경계 -| 키 | 내용 | -| --------------------------------------- | ------------------------------------------------------- | -| `member:` | 메타 `{ name }` | -| `unknown:` | 메타 `{ firstSeen, lastSeen, count }` | -| `s::<입실 시각 13자리>:` | 세션. 메타 `{ checkOut, autoClosed }` | -| `latest:` | 값 `{ checkIn, checkOut, autoClosed }` 최근 세션 포인터 | +- 플랫폼이 검증한 `ctx.access.aud`가 운영 `CF_ACCESS_AUD`와 같아야 화면·데이터에 접근할 수 있습니다. 클라이언트가 보낸 JWT 헤더·쿠키·Basic·기기 Bearer를 인증 근거로 해석하지 않습니다. +- `CF_ACCESS_AUD` 미설정은 503, 플랫폼 Access 컨텍스트 누락·다른 audience는 403입니다. `keep_vars: true`로 운영에서 관리하는 audience를 보존합니다. +- 허용 목록의 GET과 `/`의 HEAD만 받습니다. 쿼리·해시·관리자·카메라·로그인·쓰기 경로를 중계하지 않습니다. `/api/taps`와 `/api/members`는 제공하지 않습니다. +- 기존 `DEVICE_TOKEN`·`DASHBOARD_PASSWORD`를 자동 회전·삭제하지 않지만 원격 뷰어의 인증 우회 경로로 사용하지 않습니다. 신규 공개 경로나 Access 권한을 만들지 않습니다. -KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60초 늦게 보입니다. 리더 한 곳에서 태그하는 용도라 태그 판정은 같은 지역의 읽기-쓰기에 의존하며, 대시보드 수치는 그만큼 지연될 수 있습니다. 같은 카드의 거의 동시 태그는 10초 디바운스가 막습니다. +## Pi 관측 스냅샷 -## API +`VIEW_STATE`는 기존 조회 전용 KV `b519a98db46d4b088baee97a256bf0f0`의 `clubroom:view:v1`을 읽습니다. 원본 출입 원장 KV를 읽거나 쓰거나 치환하지 않습니다. 게시 작업은 별도 Pi 수집·게시 프로세스의 책임입니다. -| 경로 | 인증 | 용도 | -| --------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `POST /api/taps` | `Authorization: Bearer ` | `{ "uid": "04A1B2C3", "at": 1760000000000 }` (`at` 생략 시 서버 시각. 미래 60초·과거 7일을 벗어나면 400) | -| `GET /` | Basic (비밀번호 = `DASHBOARD_PASSWORD`, 사용자 이름 무시) | 대시보드 | -| `GET /api/state` | Basic | 현재 인원, 회원별 오늘·이번 주 시간, 최근 기록 | -| `POST /api/members` | Basic, 같은 출처 JSON | `{ "uid", "name" }` 회원 등록·이름 수정 | -| `GET /api/automation` | Basic | 독립적인 오류 수신 요약과 Rocky 읽기 전용 헬스. RFID Bearer로 접근 불가 | +스냅샷은 `{version:1, observedAt, publishedAt, payloads, assets}`이며 최대 2 MiB입니다. 관측 시각보다 게시 시각이 빠르거나 미래인 자료, 5분 초과 자료, 누락·잘못된 자료는 503입니다. 실제 LAN origin으로 우회하거나 빈 값으로 바꾸지 않습니다. -## 대시보드 +| 경로 | 응답 | +| -------------------------------------------------------- | -------------------------------------------- | +| `/` | 단일 읽기 전용 화면, HEAD 지원 | +| `/api/state` | 관측 시점의 출입 현황 | +| `/api/monitor`, `/api/koin-health`, `/api/worker-health` | Pi가 게시한 관측 시점 상태 | +| `/api/spotify`, `/api/jam` | 관측 시점 재생·참여 정보 | +| `/api/wifi-qr/2.4`, `/api/wifi-qr/5`, `/api/jam-qr` | 허용된 PNG, 개별 최대 256 KiB | +| `/api/view-status` | 스냅샷 가용성·관측/게시 시각·나이 | +| `/health` | 스냅샷 모드·관측/게시 시각·나이 | +| `/api/events` | 최대 60초 SSE, 10초마다 스냅샷 상태 확인 | +| `/api/automation` | 독립적인 오류 수신 요약·Rocky 읽기 전용 헬스 | -- 서버가 제공하는 단일 HTML 화면에 [Mobbin의 공개 업무용 대시보드 패턴](https://mobbin.com/explore/web/screens/analytics-dashboard)을 참고한 Material Design 3의 색상·서피스·타이포그래피를 적용합니다. 사이드바 없이 구성하며 외부 폰트나 UI 라이브러리를 불러오지 않습니다. -- 현재 인원과 입실 중인 회원의 체류 시간을 크게 표시하고, 전체 회원의 오늘·이번 주 체류 시간 합계와 퇴근 미기록 건수를 보여 줍니다. 회원별 표와 이번 주 최근 세션 최대 15건을 함께 제공합니다. -- 16:9 데스크톱 화면에서는 본문 스크롤 없이 주요 패널을 한 화면에 배치하고, 긴 목록은 각 패널의 이전·다음 버튼으로 넘깁니다. 화면 크기에 맞춰 표시 개수가 바뀌고 새로고침 후에도 선택한 페이지를 유지합니다. -- 테마 버튼은 자동(시스템 설정) → 라이트 → 다크 순서로 전환하며 선택을 브라우저에 저장합니다. 작은 화면에서는 카드가 재배치되고 표는 가로로 스크롤할 수 있습니다. -- 데이터는 10초마다 갱신하고 입실 중인 회원의 체류 시계는 매초 갱신합니다. 상태 조회·등록 요청은 응답 본문 수신까지 10초로 제한하며, 새로고침 버튼으로 즉시 갱신할 수 있습니다. 연결 실패 시 마지막 데이터임을 표시하고 체류 시계를 마지막 동기화 시각에 멈춥니다. 처음부터 불러오지 못한 경우 수치를 임의로 표시하지 않습니다. -- 미등록 카드는 화면에서 이름을 입력해 등록합니다. 주기적 갱신에도 입력과 포커스를 유지하며 등록 중·완료·오류 상태를 카드별로 표시합니다. 등록 전 태그는 입실 기록이 아니므로 등록 후 카드를 다시 태그해야 합니다. -- 회원 이름과 카드 정보는 `textContent`로만 출력합니다. 상태 조회와 등록은 같은 출처의 기존 인증을 사용하며 등록은 JSON 요청으로만 전송합니다. -- 배포는 BCSDLab 조직 Gateway를 요구하는 접근 정책을 전제로 합니다. 일반 소비자 WARP 접속만 허용하는 정책은 충분하지 않습니다. Worker의 모든 도메인·미리보기 우회 경로를 보호하기 전에는 공개 경로를 활성화하지 않습니다. +스냅샷 응답은 게시된 상태 코드를 보존하며 `X-BCSD-Snapshot-*` 헤더로 관측 시각을 구분합니다. 모든 응답은 private/no-store, CSP, nosniff, 프레임 차단을 적용합니다. ## 오류 수신·Rocky 읽기 전용 모니터 -- 서버의 `ERROR_AUTOMATION` Worker service binding이 고정된 `bcsd-error-automation`의 `GET /healthz`와 `GET /healthz/rocky`를 병렬로 읽습니다. 브라우저·Pi에는 관리/모델/진단/호스트 모니터 토큰을 주지 않습니다. -- 두 Worker에 별도의 동일한 `ERROR_MONITOR_TOKEN`을 secret으로 설정해야 합니다(32자 이상 무작위 값). `DEVICE_TOKEN`, `DASHBOARD_PASSWORD`, 실행기·진단·모델 토큰을 재사용하지 않습니다. secret 설정은 배포 작업이므로 정상 운영 승인 절차를 따릅니다. -- 수신 요약은 Sentry/Cloudflare의 최근 24시간 **고유 오류 등록 건수**, 최근 신규 등록 시각과 최대 10개의 처리 상태입니다. 건수와 최근 시각은 목록 제한과 독립적으로 전체 기간에서 계산합니다. 중복 웹훅 전달 횟수나 마지막 재전송 시각이 아니며 GitHub PR 리뷰와 미사용 `koin-api` 기록은 제외합니다. -- Rocky는 기존 `OMP_GATEWAY` VPC와 `koin-omp-gateway-rocky` 터널을 통해 게이트웨이의 고정 `GET /healthz`만 조회합니다. `OMP_GATEWAY_MONITOR_TOKEN`은 error-automation과 호스트만 공유하는 별도 secret이며 모델·실행기·수신 요약 토큰과 같으면 거부합니다. 호스트 설정은 [게이트웨이 운영 계약](../../scripts/autofix/README.md#rocky-읽기-전용-헬스-활성화)을 따릅니다. -- CPU는 Linux `/proc/stat` 두 표본의 busy 비율, RAM 사용량은 `MemTotal − MemAvailable`입니다. 실행기·브로커는 고정 systemd 서비스의 설치/활성/프로세스 상태를 구분합니다. 서비스 활성이나 프로세스 실행은 브로커 HTTP 응답·모델 인증·작업 완료의 증거가 아니며 모델 상태는 미확인으로 표시합니다. -- 오류 전문·프롬프트·소스·로그·원본 provider ID·자격 증명을 반환하지 않습니다. 조회가 작업 claim, 모델 생성, GitHub 작업을 시작하지 않습니다. -- 출근부 조회와 별개로 60초마다 갱신합니다. 조회별 5초 본문 수신 기한·16 KiB JSON 한도·리다이렉트 거절을 적용하고 120초보다 오래된 스냅샷은 unknown으로 처리합니다. -- `/api/automation`의 최상위 `state`/`data`는 수신 요약이고 `rocky.state`/`rocky.data`는 호스트 관측입니다. 한쪽 실패가 다른 쪽의 관측을 지우지 않습니다. 미설정·연결 실패·응답 불일치는 해당 결과의 `state: unknown`, `data: null`이며 측정 실패 CPU/RAM도 null입니다. 실제 조회한 빈 저장소만 0건으로 표시하고 조용한 수신량을 장애로 판단하지 않습니다. -- 연결 실패·오프라인·숨긴 화면·확인 기한 초과 시 마지막 관측은 과거 기록임을 표시합니다. 조직 Gateway 보호와 기존 비공개 노출 경계는 유지하며 새 터널·공개 SSH·WARP/OpenBao 권한은 추가하지 않습니다. +- `ERROR_AUTOMATION` service binding으로 고정된 `bcsd-error-automation`의 `/healthz`와 `/healthz/rocky`를 병렬 조회합니다. Pi 스냅샷이 없어도 이 조회는 독립적으로 동작합니다. +- `ERROR_MONITOR_TOKEN`은 두 Worker만 공유하는 신규 전용 secret입니다. 브라우저·Pi에는 전달하지 않으며 기기·대시보드·실행기·진단·모델 토큰과 재사용하지 않습니다. +- 수신 요약은 Sentry/Cloudflare 최근 24시간의 고유 오류 등록 건수·최근 신규 등록 시각·최대 10개 처리 상태입니다. 건수는 목록 제한과 독립적으로 계산하며 중복 전달량이 아닙니다. PR 리뷰 작업과 미사용 `koin-api` 기록은 제외합니다. +- Rocky는 error-automation이 기존 `OMP_GATEWAY` VPC와 `koin-omp-gateway-rocky` 터널의 고정 healthz를 별도 `OMP_GATEWAY_MONITOR_TOKEN`으로 읽습니다. 이 호스트 토큰은 출근부가 받지 않습니다. +- CPU는 Linux `/proc/stat` 두 표본의 busy 비율, RAM은 `MemTotal − MemAvailable`입니다. 실행기·브로커의 서비스·프로세스 상태와 브로커 HTTP 응답·모델 정상 여부를 구분합니다. 모델 생성·작업 claim·GitHub 작업은 수행하지 않습니다. +- 최상위 `state`/`data`는 수신 요약이고 `rocky.state`/`rocky.data`는 호스트 관측입니다. 실패는 해당 결과의 unknown/null이며 실제 빈 저장소만 0건입니다. 측정 실패 CPU/RAM도 null입니다. +- 60초마다 조회하며 서버 조회별 5초·16 KiB·리다이렉트 거절·UTF-8·120초 신선도 검사를 적용합니다. `servedAt`과 단조 시계로 화면의 확인 기한을 계산하므로 키오스크 벽시계에 의존하지 않습니다. +- 연결 실패·오프라인·숨김·기한 초과는 미확인으로 표시하고 마지막 관측은 과거 기록으로 구분합니다. Pi 스냅샷 표시가 중단돼도 독립 모니터는 표시합니다. +- 오류 전문·프롬프트·소스·로그·원본 provider ID·lease·자격 증명은 반환하지 않습니다. 상세 호스트 설정은 [게이트웨이 운영 계약](../../scripts/autofix/README.md#rocky-읽기-전용-헬스-활성화)을 따릅니다. -## 배포 +## 화면과 배포 -```sh -# 기존 DEVICE_TOKEN·DASHBOARD_PASSWORD는 유지합니다. 비밀값 설정·회전은 별도 운영 절차입니다. -pnpm deploy:attendance -``` - -`wrangler.jsonc`의 `ATTENDANCE`는 기존 운영 KV 네임스페이스를 고정합니다. 새 네임스페이스를 만들거나 id를 바꾸면 기존 데이터와 분리되므로 배포 중 치환하지 않습니다. 새 토큰이 필요할 때만 충분히 긴 무작위 값으로 별도 설정합니다(`openssl rand -hex 32`). +Pi 조회 화면·관측 시각·Wi-Fi/Spotify/모니터 화면을 유지하며 외부 UI 라이브러리나 Static Assets를 사용하지 않습니다. `src/dashboard.html`은 Worker Text 모듈입니다. 벽걸이 화면은 패널별 페이지를 사용하고 작은 화면에서는 재배치합니다. 회원 이름 등 데이터는 textContent로만 표시합니다. -벽걸이 화면은 대시보드 경로(`/`)를 열고 Basic 인증 창에서 `DASHBOARD_PASSWORD`로 인증합니다. 비밀번호를 URL에 넣지 않습니다. - -## 로컬 실행 +웹·오류 자동화의 정상 main 승격 후 이 Worker를 별도 배포합니다. `VIEW_STATE` ID와 외부 관리 Access audience, 기존 secret을 유지합니다. secret 설정도 배포 작업입니다. 운영 승인 없이 원장 KV·도메인·Access 정책을 바꾸거나 별도 공개 테스트 Worker를 만들지 않습니다. ```sh -printf 'DEVICE_TOKEN=dev-device-token\nDASHBOARD_PASSWORD=dev-password\n' > apps/attendance/.dev.vars -pnpm dev:attendance # http://localhost:8787 -curl -X POST localhost:8787/api/taps -H 'authorization: Bearer dev-device-token' \ - -H 'content-type: application/json' -d '{"uid":"04A1B2C3"}' +pnpm --dir apps/attendance exec wrangler types --include-runtime false +pnpm --dir apps/attendance typecheck +pnpm --dir apps/attendance test +pnpm --dir apps/attendance exec wrangler deploy --dry-run ``` -## 라즈베리파이 +로컬에서도 플랫폼 Access 컨텍스트가 없으면 403입니다. 클라이언트 헤더를 인증 우회로 추가하지 않습니다. API 회귀는 격리된 플랫폼 컨텍스트·KV fixture로 검증합니다. + +## LAN 리더 배선·설치 Raspberry Pi 3B 기준입니다. `READER`로 리더 종류를 고릅니다. +리더의 `ATTENDANCE_URL`은 승인된 LAN 수집 API 주소입니다. 이 읽기 전용 원격 Worker 주소로 태그를 전송하지 않습니다. - `rc522`: GPIO(SPI)에 직접 연결한 RC522 모듈. 카드 UID를 16진수 대문자(예: `04A1B2C3`)로 보내고, 결과를 LED 두 개로 알립니다. - `hid`(기본값): 카드 UID를 키 입력 + Enter로 보내는 USB HID 리더. 장치를 독점해 UID가 콘솔에 입력되지 않으며, 장치가 여러 개면 `READER_DEVICE`로 경로를 지정합니다. @@ -131,15 +108,15 @@ sudo systemctl daemon-reload RC522는 `/dev/spidev0.0`과 위 배선을 사용합니다. SPI 장치 파일이 있다는 것만으로 모듈 연결이 확인되지는 않습니다. 시작 시 `RC522 응답 없음`이면 3.3V 전원·GND·CE0·SCLK·MOSI·MISO·RST 배선을 확인합니다. USB HID는 `/dev/input/by-id/*-event-kbd`에 실제 리더가 있어야 합니다. -실제 Worker 주소와 해당 Worker의 `DEVICE_TOKEN`을 받은 뒤에만 런타임 설정을 작성하고 서비스를 시작합니다. 진단 중이거나 모듈·카드 인식이 확인되지 않았다면 전송 루프를 켜지 않습니다. 네트워크 접근 정책이 있는 배포는 Pi에서도 허용된 경로로 Worker에 접근할 수 있어야 합니다. +실제 LAN 수집 API 주소와 전용 기기 토큰을 받은 뒤에만 런타임 설정을 작성하고 서비스를 시작합니다. 진단 중이거나 모듈·카드 인식이 확인되지 않았다면 전송 루프를 켜지 않습니다. ```sh sudo touch /etc/attendance-reader.env sudo chown root:root /etc/attendance-reader.env sudo chmod 600 /etc/attendance-reader.env sudoedit /etc/attendance-reader.env -# ATTENDANCE_URL=<실제 Worker 기본 주소> -# DEVICE_TOKEN=<해당 Worker의 실제 토큰> +# ATTENDANCE_URL=<실제 LAN 수집 API 주소> +# DEVICE_TOKEN= # READER=rc522 (USB HID는 hid; 필요하면 READER_DEVICE도 지정) sudo systemctl enable --now attendance-reader.service ``` diff --git a/apps/attendance/src/automation.ts b/apps/attendance/src/automation.ts index 701fc50..658cf70 100644 --- a/apps/attendance/src/automation.ts +++ b/apps/attendance/src/automation.ts @@ -19,6 +19,7 @@ interface MonitorResult { export type AutomationResult = MonitorResult; export interface AutomationDashboardResult extends AutomationResult { + servedAt: number; rocky: MonitorResult; } @@ -93,6 +94,6 @@ export async function handleAutomation(env: AutomationEnv): Promise { getAutomationResult(env), getMonitorResult(env, ROCKY_HEALTH_URL, parseRockyMonitorSnapshot, Date.now), ]); - const result: AutomationDashboardResult = { ...ingress, rocky }; + const result: AutomationDashboardResult = { ...ingress, rocky, servedAt: Date.now() }; return Response.json(result, { headers: UNKNOWN_HEADERS }); } diff --git a/apps/attendance/src/dashboard.html b/apps/attendance/src/dashboard.html new file mode 100644 index 0000000..51fcfa9 --- /dev/null +++ b/apps/attendance/src/dashboard.html @@ -0,0 +1,4032 @@ + + + + + + + + BCSD 출근부 + + + + +
+
+
+
+

출입 현황

+
+ +
+ +
+ 재생 상태를 확인하고 있습니다 + Spotify Connect +
+
+
+
한국 표준시 · KST
+ + + + +
+
+ +
+
+ 오류 수신 · 24시간 + 확인 중 + Sentry —Cloudflare — + 수신 확인 — + +
+
+ Rocky + 확인 중 + CPU —RAM — 실행기 —브로커 — + 호스트 확인 — + 브로커 HTTP·모델 실행 미확인 +
+
+ +
+ +
+
+
+

관측 시 동아리방에는

+

등록 회원 확인 중

+
+
—명
+
+
+

관측 시 입실한 사람

+

동아리방 현황을 불러오고 있습니다.

+
+ +
+
+
+
+

오늘 체류 시간 중앙값

+

—

+
+
+

이번 주 체류 시간 중앙값

+

—

+
+
+

이번 주 퇴근 미기록

+

—

+
+
+
+
+

이번 주의 발자취

+ 확인 중 +
+

회원별 기록을 불러오고 있습니다.

+ + +
+
+

Wi-Fi 연결

+
+
+ +

QR 코드를 불러오는 중

+
BCSD_5G
+
+
+ +

QR 코드를 불러오는 중

+
BCSD_2.4G
+
+
+
+
+ 첫 데이터를 불러오는 중 +

+ 아직 동기화되지 않음 · KV 반영 지연 있음 · 관측 시점 기준 +

+
+
+ + +
+

BCSD LAB · 작은 태그 하나로 이어지는 우리의 하루

+

모든 시각은 KST · 관측 시점 데이터 · NFC는 LAN에서 즉시 반영

+
+
+
+ + + + +

+

5초 후 자동으로 닫힙니다.

+ +
+ +
+

오류 수신 · Rocky 관측

+ +
+

+ Pi 스냅샷과 독립적인 읽기 전용 관측입니다. CPU는 두 Linux 표본의 차이, RAM은 MemTotal − MemAvailable입니다. + 서비스·프로세스 상태는 브로커 HTTP 응답·모델 인증·작업 완료의 증거가 아닙니다. +

+

최근 정상 조회 기록이 없습니다.

+
    +
    + + + + diff --git a/apps/attendance/src/dashboard.ts b/apps/attendance/src/dashboard.ts deleted file mode 100644 index 7a23d4d..0000000 --- a/apps/attendance/src/dashboard.ts +++ /dev/null @@ -1,959 +0,0 @@ -/** - * 벽걸이 화면용 단일 HTML입니다. 서버 데이터는 모두 textContent로만 그려 - * 회원 이름 등 사용자 입력이 마크업으로 해석되지 않게 합니다. - */ -export const DASHBOARD_HTML = /* html */ ` - - - - - - -BCSD 출근부 - - - - -
    -
    -
    -
    -

    CLUBROOM / ATTENDANCE

    출입 현황

    -
    -
    -
    한국 표준시 · KST
    - - -
    -
    -
    -
    -
    -
    - 오류 수신 · 24시간 - 확인 중 - Sentry 고유 등록 — · 최근 신규 — - Cloudflare 고유 등록 — · 최근 신규 — - 수신 확인 — -
    -
    - Rocky - 확인 중 - CPU — - RAM — - 실행기 — - 브로커 — - 호스트 확인 — - 브로커 HTTP·모델 실행 미확인 -
    -
    - -
    - -
    -
    -

    지금 동아리방에는

    등록 회원 확인 중

    -
    —명
    -

    함께 몰입하는 시간, 이곳에서.

    -
    -
    -

    지금 있는 사람

    입실 현황
    -

    동아리방 현황을 불러오고 있습니다.

    -
    - -
    -
    -
    -

    오늘 함께한 시간

    —

    전체 회원 체류 시간 합계

    -

    이번 주 함께한 시간

    —

    월요일부터 지금까지 · KST

    -

    이번 주 퇴근 미기록

    —

    미기록 세션은 시간 합계에서 제외

    -
    -
    -

    이번 주의 발자취

    회원별 입실 현황과 오늘 · 이번 주 체류 시간

    확인 중
    -

    회원별 기록을 불러오고 있습니다.

    - - -

    퇴실 태그 없이 6시간이 지난 세션은 ‘퇴근 미기록’으로 표시하며 체류 시간에서 제외합니다.

    -
    -
    -
    -

    최근 기록

    이번 주 기록 중 최근 입실 순 · 최대 15건

    확인 중
    -

    최근 입실 · 퇴실 기록을 불러오고 있습니다.

    - - -
    -
    -

    미등록 카드

    카드의 주인을 연결해 주세요.

    확인 중
    -

    새로 태그한 카드를 확인하고 있습니다.

    -
    - -

    이름은 최대 30자입니다. 등록 후 카드를 다시 태그하면 입실이 기록됩니다. 미등록 카드는 7일 뒤 자동 삭제됩니다.

    -
    -
    -
    첫 데이터를 불러오는 중

    아직 동기화되지 않음 · 10초마다 갱신

    -

    -
    -

    BCSD LAB · 작은 태그 하나로 이어지는 우리의 하루

    모든 시각은 KST · 저장소 반영에 최대 약 60초 소요될 수 있습니다.

    -
    -
    - - -

    오류 수신 · Rocky 관측

    -

    -
      -

      Rocky 읽기 전용 헬스

      -

      아직 조회된 호스트 정보가 없습니다.

      -
      - - - -`; diff --git a/apps/attendance/src/index.ts b/apps/attendance/src/index.ts index f34eaa9..0f2d05e 100644 --- a/apps/attendance/src/index.ts +++ b/apps/attendance/src/index.ts @@ -1,278 +1,4 @@ -import { DAY_MS, decideTap, isUnrecorded, kstDateKey, kstWeekStart, summarize, type SessionRow } from './logic.ts'; -import { DASHBOARD_HTML } from './dashboard.ts'; -import { handleAutomation } from './automation.ts'; +import html from './dashboard.html'; +import { createViewer } from './viewer.ts'; -interface Env extends Cloudflare.Env { - /** - * 키 구성 (값은 비어 있고 데이터는 모두 메타데이터에 두어 list 한 번으로 읽습니다): - * - `member:` 메타 {name} - * - `unknown:` 메타 {firstSeen, lastSeen, count} - * - `s::<입실 시각 13자리>:` 메타 {checkOut, autoClosed} — 세션 - * - `latest:` 값 JSON {checkIn, checkOut, autoClosed} — 회원의 최근 세션 포인터 - */ - /** 라즈베리파이가 태그 이벤트를 보낼 때 쓰는 Bearer 토큰. */ - DEVICE_TOKEN: string; - /** 대시보드·회원 등록용 Basic 인증 비밀번호(사용자 이름은 무시). */ - DASHBOARD_PASSWORD: string; - /** 서버 측에서만 사용하는 오류 자동화 모니터 토큰. */ - ERROR_MONITOR_TOKEN?: string; -} - -interface MemberMeta { - name: string; -} - -interface UnknownMeta { - firstSeen: number; - lastSeen: number; - count: number; -} - -interface SessionMeta { - checkOut: number | null; - autoClosed: boolean; -} - -interface LatestSession extends SessionMeta { - checkIn: number; -} - -const UID_PATTERN = /^[0-9A-Z]{4,32}$/; -const MAX_CLOCK_SKEW_MS = 60_000; -const MAX_OFFLINE_REPLAY_MS = 7 * DAY_MS; -const RECENT_LIMIT = 15; -const UNKNOWN_TTL_SECONDS = 7 * 24 * 3600; -const SECURITY_HEADERS = { - 'cache-control': 'no-store', - 'x-content-type-options': 'nosniff', - 'referrer-policy': 'no-referrer', -}; -const UNAUTHORIZED = { - status: 401, - headers: { ...SECURITY_HEADERS, 'www-authenticate': 'Basic realm="BCSD attendance", charset="UTF-8"' }, -} as const; -const encoder = new TextEncoder(); - -function json(body: unknown, status = 200, headers: HeadersInit = {}): Response { - return Response.json(body, { status, headers: { ...SECURITY_HEADERS, ...headers } }); -} - -/** 길이·내용 모두 시간 차이 없이 비교하도록 SHA-256 다이제스트끼리 비교합니다. */ -async function safeEqual(actual: string, expected: string): Promise { - if (!expected) return false; - const [a, b] = await Promise.all([ - crypto.subtle.digest('SHA-256', encoder.encode(actual)), - crypto.subtle.digest('SHA-256', encoder.encode(expected)), - ]); - return crypto.subtle.timingSafeEqual(a, b); -} - -async function deviceAuthorized(request: Request, env: Env): Promise { - const header = request.headers.get('authorization') ?? ''; - return header.startsWith('Bearer ') && safeEqual(header.slice(7), env.DEVICE_TOKEN); -} - -async function dashboardAuthorized(request: Request, env: Env): Promise { - const header = request.headers.get('authorization') ?? ''; - if (!header.startsWith('Basic ')) return false; - let decoded: string; - try { - decoded = atob(header.slice(6)); - } catch { - return false; - } - const separator = decoded.indexOf(':'); - return separator >= 0 && safeEqual(decoded.slice(separator + 1), env.DASHBOARD_PASSWORD); -} - -/** 브라우저가 Basic 자격증명을 자동 첨부하므로 mutation은 같은 출처의 JSON 요청만 받습니다. */ -function sameOriginJson(request: Request, url: URL): boolean { - const origin = request.headers.get('origin'); - if (origin !== null && origin !== url.origin) return false; - return (request.headers.get('content-type') ?? '').toLowerCase().startsWith('application/json'); -} - -async function readJson(request: Request): Promise | null> { - try { - const body: unknown = await request.json(); - return body !== null && typeof body === 'object' && !Array.isArray(body) ? (body as Record) : null; - } catch { - return null; - } -} - -function sessionKey(uid: string, checkIn: number): string { - return `s:${kstDateKey(checkIn)}:${String(checkIn).padStart(13, '0')}:${uid}`; -} - -/** 접두사 아래 모든 키를 메타데이터와 함께 읽습니다(list는 페이지당 최대 1000개). */ -async function listAll(kv: KVNamespace, prefix: string): Promise<{ name: string; metadata: TMeta }[]> { - const result: { name: string; metadata: TMeta }[] = []; - let cursor: string | undefined; - do { - const page = await kv.list({ prefix, cursor }); - for (const key of page.keys) if (key.metadata) result.push({ name: key.name, metadata: key.metadata }); - cursor = page.list_complete ? undefined : page.cursor; - } while (cursor); - return result; -} - -async function handleTap(request: Request, env: Env): Promise { - if (!(await deviceAuthorized(request, env))) return json({ error: 'unauthorized' }, 401); - const body = await readJson(request); - if (!body || typeof body.uid !== 'string') return json({ error: 'invalid_body' }, 400); - - const uid = body.uid.trim().toUpperCase(); - if (!UID_PATTERN.test(uid)) return json({ error: 'invalid_uid' }, 400); - - const now = Date.now(); - const at = body.at === undefined ? now : body.at; - if ( - typeof at !== 'number' || - !Number.isSafeInteger(at) || - at > now + MAX_CLOCK_SKEW_MS || - at < now - MAX_OFFLINE_REPLAY_MS - ) { - return json({ error: 'invalid_time' }, 400); - } - - const kv = env.ATTENDANCE; - const [{ metadata: member }, latest] = await Promise.all([ - kv.getWithMetadata(`member:${uid}`), - kv.get(`latest:${uid}`, 'json'), - ]); - if (!member) { - const { metadata: seen } = await kv.getWithMetadata(`unknown:${uid}`); - const next: UnknownMeta = { - firstSeen: seen?.firstSeen ?? at, - lastSeen: Math.max(seen?.lastSeen ?? at, at), - count: (seen?.count ?? 0) + 1, - }; - // 등록 전 카드 UID는 개인정보이므로 일정 기간 뒤 자동으로 지웁니다. - await kv.put(`unknown:${uid}`, '', { metadata: next, expirationTtl: UNKNOWN_TTL_SECONDS }); - return json({ status: 'unknown' }, 202); - } - - const action = decideTap(latest ? { memberId: uid, ...latest } : null, at); - if (action.kind === 'ignore') return json({ status: 'ignored', name: member.name }); - - const open = (checkIn: number) => - kv.put(sessionKey(uid, checkIn), '', { metadata: { checkOut: null, autoClosed: false } satisfies SessionMeta }); - const close = (checkIn: number, checkOut: number, autoClosed: boolean) => - kv.put(sessionKey(uid, checkIn), '', { metadata: { checkOut, autoClosed } satisfies SessionMeta }); - // 세션을 먼저 쓰고 포인터를 마지막에 갱신합니다. 중간에 실패하면 포인터가 옛 값이라 다음 태그가 다시 판단합니다. - const point = (value: LatestSession) => kv.put(`latest:${uid}`, JSON.stringify(value)); - - switch (action.kind) { - case 'check-in': - await open(at); - await point({ checkIn: at, checkOut: null, autoClosed: false }); - return json({ status: 'check-in', name: member.name, at }); - case 'check-out': - await close(latest!.checkIn, at, false); - await point({ checkIn: latest!.checkIn, checkOut: at, autoClosed: false }); - return json({ status: 'check-out', name: member.name, at, durationMs: at - latest!.checkIn }); - case 'expire-and-check-in': - await close(latest!.checkIn, action.closeAt, true); - await open(at); - await point({ checkIn: at, checkOut: null, autoClosed: false }); - return json({ status: 'check-in', name: member.name, at, previousAutoClosed: true }); - } -} - -async function handleState(env: Env): Promise { - const now = Date.now(); - // 이번 주와, 자정을 걸쳐 주 경계 전날에 시작한 세션(최대 길이 6시간)까지 날짜별 접두사로 읽습니다. - const days: string[] = []; - for (let day = kstWeekStart(now) - DAY_MS; day <= now; day += DAY_MS) days.push(kstDateKey(day)); - const kv = env.ATTENDANCE; - const [members, unknown, ...sessionDays] = await Promise.all([ - listAll(kv, 'member:'), - listAll(kv, 'unknown:'), - ...days.map((day) => listAll(kv, `s:${day}:`)), - ]); - - const names = new Map(members.map((member) => [member.name.slice('member:'.length), member.metadata.name])); - const sessions: SessionRow[] = sessionDays.flat().map(({ name, metadata }) => { - const [, , checkIn, uid] = name.split(':'); - return { memberId: uid, checkIn: Number(checkIn), checkOut: metadata.checkOut, autoClosed: metadata.autoClosed }; - }); - const recent = sessions - .filter((session) => names.has(session.memberId)) - .sort((a, b) => b.checkIn - a.checkIn) - .slice(0, RECENT_LIMIT) - .map((session) => ({ - name: names.get(session.memberId), - checkIn: session.checkIn, - checkOut: session.checkOut, - // 퇴근 태그가 없어 시간 합계에서 빠진 세션(자동 종료 또는 최대 길이 초과). - unrecorded: isUnrecorded(session, now), - })); - - return json({ - now, - // 카드 UID는 화면에 필요 없으므로 내보내지 않습니다. - members: summarize( - members.map((member) => ({ id: member.name.slice('member:'.length), name: member.metadata.name })), - sessions, - now, - ).map(({ id: _id, ...summary }) => summary), - unknownCards: unknown - .map(({ name, metadata }) => ({ uid: name.slice('unknown:'.length), ...metadata })) - .sort((a, b) => b.lastSeen - a.lastSeen) - .slice(0, 20), - recent, - }); -} - -async function handleMember(request: Request, url: URL, env: Env): Promise { - if (!sameOriginJson(request, url)) return json({ error: 'invalid_request' }, 400); - const body = await readJson(request); - if (!body || typeof body.uid !== 'string' || typeof body.name !== 'string') - return json({ error: 'invalid_body' }, 400); - - const uid = body.uid.trim().toUpperCase(); - const name = body.name.trim(); - if (!UID_PATTERN.test(uid)) return json({ error: 'invalid_uid' }, 400); - if (name.length < 1 || name.length > 30) return json({ error: 'invalid_name' }, 400); - - // 같은 카드로 다시 등록하면 이름만 고칩니다. - await env.ATTENDANCE.put(`member:${uid}`, '', { metadata: { name } satisfies MemberMeta }); - await env.ATTENDANCE.delete(`unknown:${uid}`); - return json({ status: 'saved' }); -} - -export default { - async fetch(request: Request, env: Env): Promise { - const url = new URL(request.url); - - if (url.pathname === '/api/taps') { - return request.method === 'POST' - ? handleTap(request, env) - : json({ error: 'method_not_allowed' }, 405, { allow: 'POST' }); - } - - const isDashboardRoute = - url.pathname === '/' || - url.pathname === '/api/state' || - url.pathname === '/api/members' || - url.pathname === '/api/automation'; - if (!isDashboardRoute) return json({ error: 'not_found' }, 404); - if (!(await dashboardAuthorized(request, env))) return new Response('Unauthorized', UNAUTHORIZED); - - if (url.pathname === '/' && request.method === 'GET') { - return new Response(DASHBOARD_HTML, { - headers: { - ...SECURITY_HEADERS, - 'content-type': 'text/html; charset=utf-8', - 'content-security-policy': - "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", - }, - }); - } - if (url.pathname === '/api/state' && request.method === 'GET') return handleState(env); - if (url.pathname === '/api/automation' && request.method === 'GET') return handleAutomation(env); - if (url.pathname === '/api/members' && request.method === 'POST') return handleMember(request, url, env); - return json({ error: 'method_not_allowed' }, 405); - }, -} satisfies ExportedHandler; +export default createViewer(html); diff --git a/apps/attendance/src/logic.ts b/apps/attendance/src/logic.ts deleted file mode 100644 index d4240c7..0000000 --- a/apps/attendance/src/logic.ts +++ /dev/null @@ -1,137 +0,0 @@ -export const HOUR_MS = 3_600_000; -export const DAY_MS = 24 * HOUR_MS; -/** 한국 표준시는 DST가 없으므로 고정 오프셋으로 하루·주 경계를 계산합니다. */ -export const KST_OFFSET_MS = 9 * HOUR_MS; -/** 퇴실 태그를 잊은 세션이 무한정 늘어나지 않도록 하는 세션 최대 길이입니다. */ -export const MAX_SESSION_MS = 6 * HOUR_MS; -/** 같은 회원의 직전 이벤트 직후 재태그(카드 중복 인식)를 무시하는 시간입니다. */ -export const DEBOUNCE_MS = 10_000; - -export interface SessionRow { - /** 회원 식별자는 카드 UID입니다. */ - memberId: string; - checkIn: number; - checkOut: number | null; - autoClosed: boolean; -} - -export type TapAction = - | { kind: 'ignore' } - | { kind: 'check-in' } - | { kind: 'check-out' } - /** 미퇴실로 남은 세션을 `closeAt`에 자동 종료하고 새 입실을 시작합니다. */ - | { kind: 'expire-and-check-in'; closeAt: number }; - -/** - * 회원의 가장 최근 세션과 태그 시각으로 입실/퇴실을 정합니다. - * 태그는 토글이며, 오프라인 재전송으로 직전 이벤트보다 이르거나 디바운스 안에 든 태그는 버립니다. - */ -export function decideTap(latest: SessionRow | null, at: number): TapAction { - if (!latest) return { kind: 'check-in' }; - const lastEventAt = latest.checkOut ?? latest.checkIn; - if (at - lastEventAt < DEBOUNCE_MS) return { kind: 'ignore' }; - if (latest.checkOut !== null) return { kind: 'check-in' }; - if (at - latest.checkIn >= MAX_SESSION_MS) { - return { kind: 'expire-and-check-in', closeAt: latest.checkIn + MAX_SESSION_MS }; - } - return { kind: 'check-out' }; -} - -/** `ts`가 속한 KST 날짜의 00:00(UTC epoch ms)입니다. */ -export function kstDayStart(ts: number): number { - return Math.floor((ts + KST_OFFSET_MS) / DAY_MS) * DAY_MS - KST_OFFSET_MS; -} - -/** `ts`가 속한 KST 주(월요일 시작)의 시작입니다. */ -export function kstWeekStart(ts: number): number { - const dayStart = kstDayStart(ts); - // 1970-01-01(목)을 기준으로 월요일=0이 되도록 3일을 더합니다. - const daysSinceMonday = (Math.floor((dayStart + KST_OFFSET_MS) / DAY_MS) + 3) % 7; - return dayStart - daysSinceMonday * DAY_MS; -} - -export function isPresent(session: SessionRow, now: number): boolean { - return session.checkOut === null && now - session.checkIn < MAX_SESSION_MS; -} - -/** - * 퇴근 태그 없이 자동 종료됐거나 최대 길이를 넘겨 열려 있는 세션입니다. - * 실제 체류 시간을 알 수 없으므로 시간 합계에 넣지 않습니다. - */ -export function isUnrecorded(session: SessionRow, now: number): boolean { - return session.autoClosed || (session.checkOut === null && now - session.checkIn >= MAX_SESSION_MS); -} - -function overlapMs(session: SessionRow, from: number, to: number, now: number): number { - if (isUnrecorded(session, now)) return 0; - const start = Math.max(session.checkIn, from); - const end = Math.min(session.checkOut ?? now, to); - return Math.max(0, end - start); -} - -export interface MemberSummary { - id: string; - name: string; - present: boolean; - /** 현재 입실 중일 때 입실 시각. */ - since: number | null; - todayMs: number; - weekMs: number; - /** 이번 주 퇴근 태그가 없어 합계에서 뺀 세션 수. */ - unrecordedWeek: number; - /** 오늘 처음 입실한 시각. 오늘 기록이 없으면 null. */ - firstInToday: number | null; -} - -export function summarize( - members: { id: string; name: string }[], - sessions: SessionRow[], - now: number, -): MemberSummary[] { - const dayStart = kstDayStart(now); - const weekStart = kstWeekStart(now); - const byMember = new Map(); - for (const session of sessions) { - const list = byMember.get(session.memberId); - if (list) list.push(session); - else byMember.set(session.memberId, [session]); - } - - const result = members.map((member): MemberSummary => { - let todayMs = 0; - let weekMs = 0; - let unrecordedWeek = 0; - let since: number | null = null; - let firstInToday: number | null = null; - for (const session of byMember.get(member.id) ?? []) { - todayMs += overlapMs(session, dayStart, dayStart + DAY_MS, now); - weekMs += overlapMs(session, weekStart, weekStart + 7 * DAY_MS, now); - if (session.checkIn >= weekStart && isUnrecorded(session, now)) unrecordedWeek++; - if (isPresent(session, now)) since = session.checkIn; - if (session.checkIn >= dayStart && (firstInToday === null || session.checkIn < firstInToday)) { - firstInToday = session.checkIn; - } - } - return { - id: member.id, - name: member.name, - present: since !== null, - since, - todayMs, - weekMs, - unrecordedWeek, - firstInToday, - }; - }); - - return result.sort((a, b) => { - if (a.present !== b.present) return a.present ? -1 : 1; - if (a.present && b.present) return (a.since ?? 0) - (b.since ?? 0); - return b.weekMs - a.weekMs || a.name.localeCompare(b.name, 'ko'); - }); -} - -/** KV 키 접두사에 쓰는 KST 날짜(YYYYMMDD)입니다. */ -export function kstDateKey(ts: number): string { - return new Date(ts + KST_OFFSET_MS).toISOString().slice(0, 10).replaceAll('-', ''); -} diff --git a/apps/attendance/src/viewer.ts b/apps/attendance/src/viewer.ts new file mode 100644 index 0000000..6410767 --- /dev/null +++ b/apps/attendance/src/viewer.ts @@ -0,0 +1,340 @@ +import { handleAutomation, type AutomationEnv } from './automation.ts'; + +export type Env = AutomationEnv & + Partial> & { + CF_ACCESS_AUD?: string; + }; + +const SNAPSHOT_KEY = 'clubroom:view:v1'; +const MAX_AGE_MS = 300_000; +const MAX_SNAPSHOT_BYTES = 2_097_152; +const MAX_PNG_BYTES = 262_144; +const STREAM_MS = 60_000; +const STREAM_POLL_MS = 10_000; +const JSON_PATHS = [ + '/api/state', + '/api/monitor', + '/api/koin-health', + '/api/worker-health', + '/api/spotify', + '/api/jam', +] as const; +const ASSET_PATHS: Record = { + '/api/wifi-qr/2.4': true, + '/api/wifi-qr/5': true, + '/api/jam-qr': true, +}; +const READ_PATHS: Record = { + '/': true, + '/health': true, + '/api/events': true, + '/api/view-status': true, + '/api/automation': true, + '/api/state': true, + '/api/monitor': true, + '/api/koin-health': true, + '/api/worker-health': true, + '/api/spotify': true, + '/api/jam': true, + '/api/wifi-qr/2.4': true, + '/api/wifi-qr/5': true, + '/api/jam-qr': true, +}; +const CSP = + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data: https://i.scdn.co https://mosaic.scdn.co; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; +const encoder = new TextEncoder(); +const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }); + +type JsonObject = Record; +interface Payload { + status: number; + body: JsonObject; +} +interface Snapshot { + version: 1; + observedAt: number; + publishedAt: number; + payloads: Record; + assets: Record; +} +type SnapshotResult = + | { snapshot: Snapshot; error?: undefined } + | { + snapshot?: Snapshot; + error: 'snapshot_missing' | 'snapshot_malformed' | 'snapshot_unavailable' | 'snapshot_stale'; + }; + +function record(value: unknown): value is JsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function validPng(png: unknown): png is string { + if ( + typeof png !== 'string' || + png.length < 76 || + png.length > Math.ceil(MAX_PNG_BYTES / 3) * 4 || + !png.startsWith('iVBORw0KGgo') || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(png) + ) + return false; + const size = (png.length / 4) * 3 - (png.endsWith('==') ? 2 : png.endsWith('=') ? 1 : 0); + if (size > MAX_PNG_BYTES) return false; + // 전체 이미지를 복사하지 않고 제한된 PNG 헤더·트레일러만 검사한다. + const head = atob(png.slice(0, 44)); + const tail = atob(png.slice(-20)); + if ( + head.slice(8, 16) !== '\x00\x00\x00\x0dIHDR' || + head.charCodeAt(16) !== 0 || + head.charCodeAt(17) !== 0 || + head.charCodeAt(20) !== 0 || + head.charCodeAt(21) !== 0 || + head.charCodeAt(26) !== 0 || + head.charCodeAt(27) !== 0 || + head.charCodeAt(28) > 1 || + !tail.endsWith('\x00\x00\x00\x00IEND\xae\x42\x60\x82') + ) + return false; + const width = head.charCodeAt(18) * 256 + head.charCodeAt(19); + const height = head.charCodeAt(22) * 256 + head.charCodeAt(23); + return width > 0 && width <= 2048 && height > 0 && height <= 2048; +} + +function isSnapshot(value: unknown, now: number): value is Snapshot { + if ( + !record(value) || + value.version !== 1 || + Object.keys(value).length !== 5 || + typeof value.observedAt !== 'number' || + typeof value.publishedAt !== 'number' || + !Number.isSafeInteger(value.observedAt) || + !Number.isSafeInteger(value.publishedAt) || + value.observedAt <= 0 || + value.publishedAt < value.observedAt || + value.publishedAt > now || + !record(value.payloads) || + !record(value.assets) + ) + return false; + if (Object.keys(value.payloads).length !== JSON_PATHS.length) return false; + for (const path of JSON_PATHS) { + const payload = value.payloads[path]; + if ( + !record(payload) || + Object.keys(payload).length !== 2 || + typeof payload.status !== 'number' || + !Number.isInteger(payload.status) || + payload.status < 200 || + payload.status > 599 || + (payload.status >= 300 && payload.status < 400) || + payload.status === 204 || + payload.status === 205 || + !record(payload.body) + ) + return false; + } + if (typeof value.assets['/api/wifi-qr/2.4'] !== 'string' || typeof value.assets['/api/wifi-qr/5'] !== 'string') + return false; + for (const path in value.assets) { + if (!Object.hasOwn(ASSET_PATHS, path) || !validPng(value.assets[path])) return false; + } + return true; +} + +function parseSnapshot(raw: ArrayBuffer, now: number): Snapshot | undefined { + if (raw.byteLength > MAX_SNAPSHOT_BYTES) return; + const value: unknown = JSON.parse(decoder.decode(raw)); + return isSnapshot(value, now) ? value : undefined; +} + +async function loadSnapshot(env: Env): Promise { + let raw: ArrayBuffer | null; + try { + raw = await env.VIEW_STATE!.get(SNAPSHOT_KEY, 'arrayBuffer'); + } catch { + return { error: 'snapshot_unavailable' }; + } + if (raw === null) return { error: 'snapshot_missing' }; + try { + const now = Date.now(); + const snapshot = parseSnapshot(raw, now); + if (!snapshot) return { error: 'snapshot_malformed' }; + if (now - snapshot.observedAt > MAX_AGE_MS) return { snapshot, error: 'snapshot_stale' }; + return { snapshot }; + } catch { + return { error: 'snapshot_malformed' }; + } +} + +function headers(contentType: string, snapshot?: Snapshot): Headers { + const result = new Headers({ + 'content-type': contentType, + 'cache-control': 'private, no-store', + 'content-security-policy': CSP, + 'x-content-type-options': 'nosniff', + 'x-frame-options': 'DENY', + 'referrer-policy': 'no-referrer', + 'permissions-policy': 'camera=(), microphone=(), geolocation=()', + 'x-bcsd-view-mode': 'eventually-consistent-snapshot', + }); + if (snapshot) { + result.set('x-bcsd-snapshot-observed-at', String(snapshot.observedAt)); + result.set('x-bcsd-snapshot-published-at', String(snapshot.publishedAt)); + result.set('x-bcsd-snapshot-age-ms', String(Date.now() - snapshot.observedAt)); + } + return result; +} + +function failure(status: number, error: string, snapshot?: Snapshot, allow?: string): Response { + const responseHeaders = headers('application/json; charset=utf-8', snapshot); + if (allow) responseHeaders.set('allow', allow); + return new Response(JSON.stringify({ error, mode: 'hosted_snapshot' }), { status, headers: responseHeaders }); +} + +function events(request: Request, env: Env, initial: Snapshot, deadline: number): Response { + let ended = false; + let poll: ReturnType; + let lifetime: ReturnType; + let output: ReadableStreamDefaultController; + let version = `${initial.observedAt}:${initial.publishedAt}`; + const cleanup = () => { + clearTimeout(poll); + clearTimeout(lifetime); + request.signal.removeEventListener('abort', clientLeft); + }; + const stop = (close: boolean) => { + if (ended) return; + ended = true; + cleanup(); + if (close) output.close(); + }; + const clientLeft = () => stop(true); + const emit = (event: string, body: unknown) => + output.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(body)}\n\n`)); + const check = async () => { + if (ended) return; + const result = await loadSnapshot(env); + if (ended) return; + if (Date.now() >= deadline) { + stop(true); + return; + } + if (result.error) { + emit('unavailable', { error: result.error }); + stop(true); + return; + } + const next = `${result.snapshot.observedAt}:${result.snapshot.publishedAt}`; + if (next !== version) { + version = next; + emit('state', { version }); + } else { + emit('ping', {}); + } + poll = setTimeout(() => { + void check(); + }, STREAM_POLL_MS); + }; + const body = new ReadableStream({ + start(controller) { + output = controller; + request.signal.addEventListener('abort', clientLeft, { once: true }); + if (request.signal.aborted) { + stop(true); + return; + } + emit('state', { version }); + poll = setTimeout(() => { + void check(); + }, STREAM_POLL_MS); + lifetime = setTimeout(() => stop(true), Math.max(0, deadline - Date.now())); + }, + cancel() { + stop(false); + }, + }); + const responseHeaders = headers('text/event-stream; charset=utf-8', initial); + responseHeaders.set('x-accel-buffering', 'no'); + return new Response(body, { headers: responseHeaders }); +} + +/** 기존 LAN 템플릿으로 빌드한 HTML이며 Worker Static Assets는 사용하지 않는다. */ +export function createViewer(html: string) { + return { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const deadline = Date.now() + STREAM_MS; + const url = new URL(request.url); + // 카메라·관리자·인증·쓰기 경로는 원격으로 중계하지 않는다. + if (!Object.hasOwn(READ_PATHS, url.pathname)) return failure(404, 'not_found'); + if (request.method !== 'GET' && !(url.pathname === '/' && request.method === 'HEAD')) { + return failure(405, 'method_not_allowed', undefined, url.pathname === '/' ? 'GET, HEAD' : 'GET'); + } + if (request.url.includes('?') || url.hash) return failure(400, 'query_not_allowed'); + if ( + typeof env.CF_ACCESS_AUD !== 'string' || + !env.CF_ACCESS_AUD || + env.CF_ACCESS_AUD.length > 256 || + env.CF_ACCESS_AUD.trim() !== env.CF_ACCESS_AUD + ) { + return failure(503, 'private_view_unconfigured'); + } + // 플랫폼 인증만 신뢰하고 클라이언트 헤더·쿠키를 권한으로 해석하지 않는다. + if (!ctx?.access || ctx.access.aud !== env.CF_ACCESS_AUD) return failure(403, 'access_required'); + if (request.signal.aborted) return failure(499, 'client_closed'); + if (url.pathname === '/api/automation') { + const response = await handleAutomation(env); + for (const [name, value] of headers('application/json; charset=utf-8')) response.headers.set(name, value); + response.headers.set('x-bcsd-view-mode', 'live-automation-read-only'); + return response; + } + if (url.pathname === '/') { + return new Response(request.method === 'HEAD' ? null : html, { headers: headers('text/html; charset=utf-8') }); + } + const result = await loadSnapshot(env); + if (url.pathname === '/api/view-status') { + const snapshot = result.snapshot; + return new Response( + JSON.stringify({ + available: !result.error, + observedAt: snapshot?.observedAt ?? null, + publishedAt: snapshot?.publishedAt ?? null, + ageMs: snapshot ? Date.now() - snapshot.observedAt : null, + }), + { + status: result.error ? 503 : 200, + headers: headers('application/json; charset=utf-8', snapshot), + }, + ); + } + if (result.error) return failure(503, result.error, result.snapshot); + const snapshot = result.snapshot; + if (url.pathname === '/api/events') { + if (Date.now() >= deadline) return failure(503, 'snapshot_unavailable'); + return events(request, env, snapshot, deadline); + } + if (url.pathname === '/health') { + return new Response( + JSON.stringify({ + mode: 'hosted_snapshot', + consistency: 'eventual', + observedAt: snapshot.observedAt, + publishedAt: snapshot.publishedAt, + ageMs: Date.now() - snapshot.observedAt, + }), + { headers: headers('application/json; charset=utf-8', snapshot) }, + ); + } + if (Object.hasOwn(ASSET_PATHS, url.pathname)) { + const png = snapshot.assets[url.pathname]; + if (!png) return failure(404, 'snapshot_asset_missing', snapshot); + const binary = atob(png); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new Response(bytes, { headers: headers('image/png', snapshot) }); + } + const payload = snapshot.payloads[url.pathname]; + return new Response(JSON.stringify(payload.body), { + status: payload.status, + headers: headers('application/json; charset=utf-8', snapshot), + }); + }, + } satisfies ExportedHandler; +} diff --git a/apps/attendance/test/automation.test.mjs b/apps/attendance/test/automation.test.mjs index 732f2f4..92e7aba 100644 --- a/apps/attendance/test/automation.test.mjs +++ b/apps/attendance/test/automation.test.mjs @@ -1,16 +1,10 @@ import assert from 'node:assert/strict'; -import { timingSafeEqual } from 'node:crypto'; import test from 'node:test'; -import worker from '../src/index.ts'; +import { createViewer } from '../src/viewer.ts'; import { getAutomationResult, handleAutomation } from '../src/automation.ts'; -// Workers expose crypto.subtle.timingSafeEqual; Node's Web Crypto does not. -if (!globalThis.crypto.subtle.timingSafeEqual) { - Object.defineProperty(globalThis.crypto.subtle, 'timingSafeEqual', { - value: (left, right) => timingSafeEqual(Buffer.from(left), Buffer.from(right)), - configurable: true, - }); -} +const worker = createViewer('
      private viewer
      '); +const context = { access: { aud: 'test-private-audience' } }; const NOW = Date.UTC(2026, 10, 13, 12); const TOKEN = 'm'.repeat(32); @@ -32,15 +26,16 @@ const snapshot = (overrides = {}) => ({ const json = (value) => Response.json(value); const binding = (fetch) => ({ fetch }); const env = (fetch) => ({ + CF_ACCESS_AUD: 'test-private-audience', ERROR_AUTOMATION: binding(fetch), ERROR_MONITOR_TOKEN: TOKEN, DASHBOARD_PASSWORD: 'secret', DEVICE_TOKEN: 'device-secret', - ATTENDANCE: new Proxy( + VIEW_STATE: new Proxy( {}, { get: () => { - throw new Error('monitor must never touch attendance KV'); + throw new Error('monitor must never touch Pi snapshot KV'); }, }, ), @@ -48,8 +43,7 @@ const env = (fetch) => ({ const request = (headers = {}, path = '/api/automation') => new Request('https://attendance.example' + path, { headers }); -// The route, not a test-only wrapper, must enforce dashboard Basic before any binding call. -test('automation route rejects unauthenticated and RFID Bearer callers before upstream access', async (t) => { +test('platform Access context, not client headers, authorizes the independent monitor route', async (t) => { t.mock.method(Date, 'now', () => NOW); let calls = 0; const workerEnv = env(async (url) => { @@ -59,20 +53,32 @@ test('automation route rejects unauthenticated and RFID Bearer callers before up ); return json(snapshot()); }); - for (const headers of [{}, { authorization: 'Bearer device-secret' }, { authorization: 'Basic !!!' }]) { - const response = await worker.fetch(request(headers), workerEnv); - assert.equal(response.status, 401); - assert.match(response.headers.get('www-authenticate'), /Basic/); + for (const headers of [ + {}, + { authorization: 'Bearer device-secret' }, + { authorization: AUTH }, + { 'cf-access-jwt-assertion': 'forged', cookie: 'CF_Authorization=forged' }, + ]) { + assert.equal((await worker.fetch(request(headers), workerEnv, {})).status, 403); } - assert.equal(calls, 0); - const response = await worker.fetch( - request({ authorization: AUTH }, '/api/automation?url=https%3A%2F%2Fattacker.example%2Fhealthz'), - workerEnv, + assert.equal((await worker.fetch(request(), workerEnv, { access: { aud: 'wrong-app' } })).status, 403); + assert.equal( + ( + await worker.fetch( + request({}, '/api/automation?url=https%3A%2F%2Fattacker.example%2Fhealthz'), + workerEnv, + context, + ) + ).status, + 400, ); + assert.equal(calls, 0); + const response = await worker.fetch(request(), workerEnv, context); assert.equal(response.status, 200); - assert.equal(calls, 2); - assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(response.headers.get('cache-control'), 'private, no-store'); + assert.equal(response.headers.get('x-bcsd-view-mode'), 'live-automation-read-only'); assert.equal((await response.json()).state, 'ok'); + assert.equal(calls, 2); }); test('fixed service binding, HTTPS path, private token, and no redirect follow', async () => { diff --git a/apps/attendance/test/logic.test.mjs b/apps/attendance/test/logic.test.mjs deleted file mode 100644 index 4567b14..0000000 --- a/apps/attendance/test/logic.test.mjs +++ /dev/null @@ -1,89 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - DAY_MS, - DEBOUNCE_MS, - HOUR_MS, - MAX_SESSION_MS, - decideTap, - kstDayStart, - kstWeekStart, - summarize, -} from '../src/logic.ts'; - -// 2026-11-10(화) 00:00 KST -const TUE = Date.UTC(2026, 10, 9, 15, 0, 0); -const MON = TUE - DAY_MS; - -const session = (overrides) => ({ memberId: '가', checkIn: TUE, checkOut: null, autoClosed: false, ...overrides }); - -test('KST 날짜·주 경계는 UTC 자정이 아니라 한국 시간 기준이다', () => { - assert.equal(kstDayStart(TUE + 5 * HOUR_MS), TUE); - assert.equal(kstDayStart(TUE - 1), TUE - DAY_MS); - assert.equal(kstWeekStart(TUE + 5 * HOUR_MS), MON); - assert.equal(kstWeekStart(MON), MON); - // 일요일 23:59는 직전 월요일이 속한 주 - assert.equal(kstWeekStart(MON - 1), MON - 7 * DAY_MS); -}); - -test('태그는 입실과 퇴실을 번갈아 만든다', () => { - assert.deepEqual(decideTap(null, TUE), { kind: 'check-in' }); - assert.deepEqual(decideTap(session({}), TUE + HOUR_MS), { kind: 'check-out' }); - assert.deepEqual(decideTap(session({ checkOut: TUE + HOUR_MS }), TUE + 2 * HOUR_MS), { kind: 'check-in' }); -}); - -test('디바운스 안의 태그와 직전 이벤트보다 이른 재전송은 무시한다', () => { - assert.deepEqual(decideTap(session({}), TUE + DEBOUNCE_MS - 1), { kind: 'ignore' }); - assert.deepEqual(decideTap(session({}), TUE - HOUR_MS), { kind: 'ignore' }); - assert.deepEqual(decideTap(session({}), TUE + DEBOUNCE_MS), { kind: 'check-out' }); -}); - -test('최대 길이를 넘긴 미퇴실 세션은 최대 길이 시점에 자동 종료하고 새로 입실한다', () => { - assert.deepEqual(decideTap(session({}), TUE + MAX_SESSION_MS), { - kind: 'expire-and-check-in', - closeAt: TUE + MAX_SESSION_MS, - }); - assert.deepEqual(decideTap(session({}), TUE + MAX_SESSION_MS - 1), { kind: 'check-out' }); -}); - -test('체류 시간은 오늘/이번 주 경계로 잘리고 입실 중이면 현재까지 더한다', () => { - const now = TUE + 3 * HOUR_MS; - const members = [ - { id: '가', name: '가' }, - { id: '나', name: '나' }, - ]; - const sessions = [ - // 월요일 22:00 → 화요일 01:00: 오늘 1시간, 이번 주 3시간 - session({ memberId: '가', checkIn: TUE - 2 * HOUR_MS, checkOut: TUE + HOUR_MS }), - // 화요일 02:00부터 입실 중: 현재까지 1시간 - session({ memberId: '가', checkIn: TUE + 2 * HOUR_MS }), - // 지난주 세션은 합산하지 않는다 - session({ memberId: '나', checkIn: MON - 8 * DAY_MS, checkOut: MON - 8 * DAY_MS + HOUR_MS }), - ]; - const [first, second] = summarize(members, sessions, now); - assert.equal(first.name, '가'); - assert.equal(first.present, true); - assert.equal(first.since, TUE + 2 * HOUR_MS); - assert.equal(first.todayMs, 2 * HOUR_MS); - assert.equal(first.weekMs, 4 * HOUR_MS); - assert.equal(first.firstInToday, TUE + 2 * HOUR_MS); - assert.equal(second.present, false); - assert.equal(second.weekMs, 0); -}); - -test('퇴근 태그가 없는 세션은 입실 중으로 보지 않고 시간 합계에서 빼며 건수만 센다', () => { - const now = TUE + 20 * HOUR_MS; - const sessions = [ - // 최대 길이를 넘겨 열려 있는 세션 - session({ checkIn: TUE }), - // 다음 태그로 자동 종료된 세션 - session({ checkIn: TUE + 8 * HOUR_MS, checkOut: TUE + 8 * HOUR_MS + MAX_SESSION_MS, autoClosed: true }), - // 정상 세션 1시간 - session({ checkIn: TUE + 17 * HOUR_MS, checkOut: TUE + 18 * HOUR_MS }), - ]; - const [member] = summarize([{ id: '가', name: '가' }], sessions, now); - assert.equal(member.present, false); - assert.equal(member.todayMs, HOUR_MS); - assert.equal(member.weekMs, HOUR_MS); - assert.equal(member.unrecordedWeek, 2); -}); diff --git a/apps/attendance/test/viewer.test.mjs b/apps/attendance/test/viewer.test.mjs new file mode 100644 index 0000000..d692379 --- /dev/null +++ b/apps/attendance/test/viewer.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createViewer } from '../src/viewer.ts'; + +const worker = createViewer('
      readonly snapshot viewer
      '); +const NOW = Date.UTC(2026, 10, 14, 1); +const context = { access: { aud: 'private-app' } }; +const paths = ['/api/state', '/api/monitor', '/api/koin-health', '/api/worker-health', '/api/spotify', '/api/jam']; +const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6t8sAAAAASUVORK5CYII='; +function snapshot(observedAt = NOW) { + return { + version: 1, + observedAt, + publishedAt: NOW, + payloads: Object.fromEntries(paths.map((path) => [path, { status: 200, body: { path, measured: true } }])), + assets: { '/api/wifi-qr/2.4': png, '/api/wifi-qr/5': png }, + }; +} +function harness(value) { + let reads = 0; + const env = { + CF_ACCESS_AUD: 'private-app', + VIEW_STATE: { + async get(key, format) { + reads++; + assert.equal(key, 'clubroom:view:v1'); + assert.equal(format, 'arrayBuffer'); + return value === null ? null : new TextEncoder().encode(JSON.stringify(value)).buffer; + }, + }, + }; + const request = (path, options = {}, ctx = context) => + worker.fetch(new Request('https://viewer.example' + path, options), env, ctx); + return { env, request, reads: () => reads }; +} + +test('the viewer rejects client-asserted authentication and mutations without reading attendance data', async () => { + const h = harness(snapshot()); + assert.equal( + ( + await h.request( + '/api/state', + { headers: { 'cf-access-jwt-assertion': 'forged', cookie: 'CF_Authorization=forged' } }, + {}, + ) + ).status, + 403, + ); + assert.equal((await h.request('/api/state', {}, { access: { aud: 'other-app' } })).status, 403); + for (const path of ['/api/taps', '/api/members', '/api/login', '/api/logout', '/api/camera']) + assert.equal((await h.request(path, { method: 'POST' })).status, 404); + assert.equal((await h.request('/api/state', { method: 'POST' })).status, 405); + assert.equal((await h.request('/api/state?url=https://attacker.example')).status, 400); + assert.equal(h.reads(), 0); +}); + +test('the freshness boundary does not hide an observed upstream failure', async (t) => { + t.mock.method(Date, 'now', () => NOW); + const data = snapshot(NOW - 300000); + data.payloads['/api/koin-health'] = { status: 503, body: { available: false } }; + const h = harness(data); + const state = await h.request('/api/state'); + assert.equal(state.status, 200); + assert.equal(state.headers.get('x-bcsd-snapshot-observed-at'), String(NOW - 300000)); + assert.equal(state.headers.get('cache-control'), 'private, no-store'); + const health = await h.request('/api/koin-health'); + assert.equal(health.status, 503); +}); + +test('missing, malformed and stale Pi snapshots fail closed while independent automation remains available', async (t) => { + t.mock.method(Date, 'now', () => NOW); + for (const value of [null, snapshot(NOW - 300001), { ...snapshot(), payloads: {} }]) { + const h = harness(value); + assert.equal((await h.request('/api/state')).status, 503); + const status = await h.request('/api/view-status'); + assert.equal(status.status, 503); + assert.equal((await status.json()).available, false); + const before = h.reads(); + const monitor = await h.request('/api/automation'); + assert.equal(monitor.status, 200); + const result = await monitor.json(); + assert.equal(result.state, 'unknown'); + assert.equal(result.data, null); + assert.equal(result.rocky.state, 'unknown'); + assert.equal(h.reads(), before); + } +}); diff --git a/apps/attendance/worker-configuration.d.ts b/apps/attendance/worker-configuration.d.ts index 2e8c08e..62dd523 100644 --- a/apps/attendance/worker-configuration.d.ts +++ b/apps/attendance/worker-configuration.d.ts @@ -1,7 +1,7 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: f10478503a80b9bd2db903e0b46e7468) +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: a15f894d54be9cfb91546592a274247a) interface __BaseEnv_Env { - ATTENDANCE: KVNamespace; + VIEW_STATE: KVNamespace; ERROR_AUTOMATION: Fetcher /* bcsd-error-automation */; } declare namespace Cloudflare { @@ -11,11 +11,11 @@ declare namespace Cloudflare { interface Env extends __BaseEnv_Env {} } interface Env extends __BaseEnv_Env {} -declare module "*.txt" { +declare module "*.html" { const value: string; export default value; } -declare module "*.html" { +declare module "*.txt" { const value: string; export default value; } diff --git a/apps/attendance/wrangler.jsonc b/apps/attendance/wrangler.jsonc index 1fe3d5a..f28b6c9 100644 --- a/apps/attendance/wrangler.jsonc +++ b/apps/attendance/wrangler.jsonc @@ -3,10 +3,13 @@ "name": "bcsd-attendance", "main": "src/index.ts", "compatibility_date": "2026-10-29", - "workers_dev": false, + "workers_dev": true, "preview_urls": false, "observability": { "enabled": true }, - // 기존 운영 네임스페이스를 재사용합니다. 새 네임스페이스로 치환하지 않습니다. - "kv_namespaces": [{ "binding": "ATTENDANCE", "id": "2cc47a111f334c4ea99deb2435263e00" }], + // 운영 플랫폼 Access audience 등 외부 관리 vars를 보존합니다. + "keep_vars": true, + "rules": [{ "type": "Text", "globs": ["**/*.html"], "fallthrough": true }], + // 원본 출입 원장 KV는 삭제·치환하지 않으며 뷰어는 별도 스냅샷만 읽습니다. + "kv_namespaces": [{ "binding": "VIEW_STATE", "id": "b519a98db46d4b088baee97a256bf0f0" }], "services": [{ "binding": "ERROR_AUTOMATION", "service": "bcsd-error-automation" }], }