Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,9 @@ OY_RELAY_TOKEN=
# 로컬 릴레이 실행용 선택 설정
OY_RELAY_PORT=4319
OY_BROWSER_EXECUTABLE=

# 선택: Cloudflare Access로 보호한 올리브영 중계의 서비스 인증
OY_ACCESS_CLIENT_ID=
OY_ACCESS_CLIENT_SECRET=
# 로컬 중계 전용 상태 디렉터리. 호출 원장과 브라우저 소유 마커를 보관합니다.
# OY_RELAY_STATE_DIR=/Users/daisorelay/Library/Application Support/DaisoRelay/state
55 changes: 49 additions & 6 deletions docs/oliveyoung-free-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ Cloudflare Worker에서 쓰려면 승인된 별도 연결 경로가 필요합니

`POST /v1/oliveyoung/{operation}`에 JSON 본문과 `Authorization: Bearer …`를 보냅니다. 대상 호스트는 `https://www.oliveyoung.co.kr`로 고정하며 임의 URL은 받지 않습니다.

| operation | JSON 필드 |
| --- | --- |
| `find-store` | `lat`, `lon`, `pageIdx`, `searchWords`, `pogKeys`, `serviceKeys`, `mapLat`, `mapLon` |
| `product-search-v3` | `includeSoldOut`, `keyword`, `page`, `sort`, `size` |
| `stock-goods-info-v3` | `goodsNo` |
| `stock-stores` | `productId`, `lat`, `lon`, `pageIdx`, `searchWords`, `mapLat`, `mapLon` |
| operation | JSON 필드 |
| --------------------- | ------------------------------------------------------------------------------------ |
| `find-store` | `lat`, `lon`, `pageIdx`, `searchWords`, `pogKeys`, `serviceKeys`, `mapLat`, `mapLon` |
| `product-search-v3` | `includeSoldOut`, `keyword`, `page`, `sort`, `size` |
| `stock-goods-info-v3` | `goodsNo` |
| `stock-stores` | `productId`, `lat`, `lon`, `pageIdx`, `searchWords`, `mapLat`, `mapLon` |

숫자·문자열·불리언 타입과 필드 목록을 검증합니다. 요청 본문은 최대 16 KiB입니다. 인증 실패는 브라우저 작업 전에 401, 잘못된 경로는 404, 잘못된 본문은 400/413입니다. 한 브라우저 작업만 동시에 실행하며, 실행·대기를 합쳐 8개를 넘으면 503입니다. 취소되었거나 15초 이상 대기한 요청은 실행하지 않습니다. 브라우저 fetch는 본문을 읽을 때까지 15초 제한을 적용합니다. 정상 HTTP 200과 `SUCCESS` JSON만 반환하며, HTML·상태 오류·네트워크 실패는 원문이나 비밀을 노출하지 않는 502가 됩니다.

Expand All @@ -50,3 +50,46 @@ Cloudflare Worker에서 쓰려면 승인된 별도 연결 경로가 필요합니
- 테스트: `npx vitest run tests/relay tests/services/oliveyoung tests/api/oliveyoung-handlers.test.ts tests/app/app-api-oliveyoung.test.ts tests/app/app-api-actions.test.ts`.

공개 Worker에서 이 로컬 Mac으로 연결되는 경로는 이번 smoke로 검증한 범위에 포함되지 않습니다.

## 상시 운영 보강

중계는 한 브라우저·한 컨텍스트·한 페이지를 재사용합니다. 요청마다 탭을 만들지 않습니다. 추가 페이지는 즉시 회수하며 회수 실패·페이지 crash·Node 측 watchdog timeout에는 소유한 브라우저 세션을 폐기합니다. 200회 조회 또는 30분 사용 후 유휴 경계에서 정리하고 다음 요청에 새 세션을 만듭니다.

브라우저는 별도 guard 프로세스가 소유합니다. 중계 부모가 SIGKILL로 종료되어도 IPC 단절을 감지해 회수합니다. PID·생성 시각·프로세스 그룹을 확인한 소유 프로세스만 종료합니다. macOS에서 별도 그룹으로 분리되는 crashpad 보조 프로세스도 실행별 고유 경로로 식별해 메모리 집계와 회수에 포함합니다. 브라우저에는 GUI 실행에 필요한 환경만 전달하고 운영 토큰은 제외합니다. 정상 종료가 지연되면 확인된 그룹을 강제 종료하고 실제 소멸을 확인합니다. 개인 Chrome을 프로세스 이름으로 일괄 종료하지 않습니다. guard까지 강제 종료되거나 소유권 확인이 실패한 경우 소유 마커를 유지하여 재시작을 차단합니다. 이 경우 운영자가 잔존 프로세스를 확인해야 합니다. 소유 확인 없이 마커 파일을 지우면 안 됩니다.

소유 브라우저 프로세스 그룹의 RSS를 30초마다 점검하며 1GiB를 넘으면 회수합니다. 이는 순간적인 메모리 사용까지 막는 OS 하드 제한은 아닙니다. 설치 설정은 Node heap을 256MiB로 제한합니다. 브라우저 응답은 최대 2MiB이며 본문을 스트리밍으로 읽으면서 제한합니다. 인증된 `GET /health`에서 탭 수·호출 수·메모리 관측값과 상태를 확인합니다.

### 전체 호출량과 인증

모든 중계 호출은 전체 분당 30회, UTC 하루 3,000회로 제한합니다. MCP·REST 모두 같은 중계를 거치므로 호출 경로로 우회할 수 없습니다. 원장을 `OY_RELAY_STATE_DIR`에 원자적으로 저장하며 저장 실패·손상은 조회 거절로 처리합니다. 본문 업로드 중인 요청도 최대 8개 슬롯에 포함합니다. 실패한 원본 요청도 이미 소비한 예산을 돌려주지 않습니다.

Cloudflare Access 사용 시 Worker secrets에 `OY_ACCESS_CLIENT_ID`와 `OY_ACCESS_CLIENT_SECRET`을 함께 설정합니다. 기존 `OY_RELAY_TOKEN`과 별개입니다. 사용자 도구 입력으로 전달하거나 덮어쓸 수 없습니다. Access 비밀이 다른 주소로 전달되지 않도록 리다이렉트를 거절합니다. Access 정책은 해당 서비스 토큰만 허용하는 Service Auth로 구성하며, Tunnel은 localhost:4319와 최종 404 경로만 연결합니다. 브라우저 디버깅 주소와 개인 네트워크 경로는 연결하지 않습니다.

### macOS 전용 계정 설치

시스템 설정에서 일반 사용자 계정 `daisorelay`를 만듭니다. 관리자 계정은 설치 스크립트가 거절합니다. 코드와 전용 브라우저는 `/Library/Application Support/DaisoRelay`에 root 소유로 설치하고, 토큰과 상태는 전용 계정 홈에만 둡니다. 자동 로그인은 설정하지 않습니다. 계정 생성과 최초 GUI 로그인은 사용자가 직접 수행해야 합니다.

설치 도구는 기본적으로 변경 없는 계획만 출력합니다. `--apply`는 검토 후 사용자의 관리자 터미널에서 실행합니다. Cloudflare 자격증명 파일은 별도로 준비된 mode 0600 파일을 지정하며 채팅·Git에 복사하지 않습니다.

```sh
/usr/bin/python3 scripts/relay/install-macos.py \
--browser-app '/absolute/path/Google Chrome for Testing.app' \
--credentials '/absolute/private/path/cf-private.json'
```

실제 설치는 같은 명령 앞에 `sudo`, 끝에 `--apply`를 붙입니다. root 권한으로 npm 설치나 외부 코드를 다운로드하지 않으며, 사전에 검증한 코드·의존성·브라우저를 복사합니다. 기존 설치가 있으면 자동 덮어쓰지 않고 중단합니다.

두 LaunchAgent는 `daisorelay`의 GUI 로그인 시 시작합니다. 브라우저의 GUI 세션이 필요하므로 부팅만 완료된 로그인 화면에서는 조회를 제공하지 않습니다. 실패 재시작은 최소 60초 간격이며, 중계의 정상 종료는 자동 재시작하지 않습니다. stdout/stderr은 `/dev/null`로 보내므로 디스크 로그가 누적되지 않습니다. 진단은 인증된 health와 launchctl 종료 상태로 확인합니다.

전용 계정의 터미널에서 아래 명령으로 두 작업을 내릴 수 있습니다.

```sh
launchctl bootout "gui/$(id -u)/page.aka.daiso-oliveyoung-tunnel"
launchctl bootout "gui/$(id -u)/page.aka.daiso-oliveyoung"
```

업그레이드는 두 작업을 중지하고 소유 브라우저가 없는 것을 확인한 뒤 수행해야 합니다. 기존 원장은 보존합니다. 전용 계정도 시스템에서 읽기 허용한 파일에는 접근할 수 있으므로, 개인 비밀은 기존 파일 권한으로 보호해야 합니다.

### 2026-09-15 운영 보강 검증

Playwright Chromium 153.0.8010.12에서 sandbox를 켜고 검증했습니다. 실제 조회 두 건과 부모 SIGKILL 후 잔여 소유 프로세스 0개를 확인했습니다. 모의 브라우저 API 조회 400회로 두 번 교체하고 각 교체 후에도 잔여 프로세스 0개를 확인했습니다. 팝업, 실제 renderer crash, 18초 Node watchdog도 검증했습니다. 메모리는 시점별 약 519~632MiB로 관측됐으며 장기간 무누수나 최고 사용량 보장을 의미하지 않습니다.
13 changes: 13 additions & 0 deletions docs/release-1.2.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# 1.2.1 릴리스 노트

## 올리브영 중계 운영

중계 브라우저의 탭 재사용, 사용량·시간에 따른 세션 정리, RSS 감시와 요청 watchdog을 추가했습니다. 별도 guard가 브라우저를 소유하며 부모 프로세스의 강제 종료에도 회수를 담당합니다. 소유한 프로세스의 종료를 확인하지 못하면 새 브라우저를 만들지 않습니다.

모든 중계 조회에 재시작 후에도 유지되는 분당 30회·UTC 일일 3,000회 상한을 적용했습니다. 본문 읽기 중인 요청도 8개 슬롯 상한에 포함하며 브라우저 응답은 2MiB로 제한합니다.

Cloudflare Access 서비스 인증을 Worker 운영 바인딩에서 전달합니다. 리다이렉트를 거절해 인증 정보가 다른 서버로 전달되지 않게 했습니다. 중계용 전용 표준 macOS 계정 설치 도구와 운영 절차를 추가했습니다.

## 적용 범위

Zyte 사용 중지 정책은 유지합니다. 중계 코드의 배포만으로 Mac과 Cloudflare가 연결되지는 않습니다. 운영자는 전용 GUI 계정, Access 정책, Tunnel, DNS와 Worker secrets를 구성하고 실제 조회를 검증해야 합니다. 설치 도구의 기본 실행은 변경 없는 계획 출력입니다.
40 changes: 40 additions & 0 deletions docs/superpowers/plans/2026-09-15-relay-operations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Mac 중계 운영 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox syntax for tracking.

**Goal:** 승인된 Mac 중계를 제한된 자원과 인증 경계 안에서 운영합니다.
**Architecture:** Worker → Access → Tunnel → localhost 중계 → 소유한 전용 Chromium. 계정·프로세스·입출력·요청량을 제한합니다.
**Tech Stack:** Node.js, Playwright, Vitest, macOS launchd, Cloudflare APIs.

## Task 1: 브라우저 수명과 서버 종료

Files: scripts/relay/start.ts, browser.ts, 새 lifecycle/소유 프로세스 모듈, tests/relay 대응 테스트.

- [x] 기존 baseline 테스트를 확인합니다.
- [x] RED: 반복 요청에 `newPage` 한 번, 교체 전 `close` 완료, close 지연·실패에 새 launch 0회, 팝업 회수·crash·timeout·shutdown 테스트를 작성하고 실패를 확인합니다.
- [x] GREEN: 단일 소유 세션과 Node watchdog, 30분/200회 교체, RSS 1GiB 점검, bounded graceful/force cleanup을 구현합니다. 소유 여부 확인 없는 PID 종료는 금지합니다.
- [x] 본문 2MiB 상한, health 상태와 종료/리스너 정리를 검증합니다. 수명 모듈을 coverage 대상에 포함합니다.
- [x] 사양 리뷰 후 품질 리뷰를 수행합니다.

## Task 2: 인증 전달과 호출량 제한

Files: src/services/oliveyoung/transport.ts, src/api/response.ts, REST/MCP 옵션 전달부, scripts/relay/quota.ts, oliveyoung.ts, tests.

- [x] RED: Access 두 헤더 전달·부분 설정 거절·인자 override 금지, 30/분·3000/일 경계와 재시작 보존·저장 실패 테스트를 작성합니다.
- [x] GREEN: Worker secret 바인딩에서만 Access 헤더를 전달합니다. 파일 기반 원자적 quota와 본문 읽기 전 큐 슬롯 확보를 구현합니다.
- [x] 사양 리뷰 후 품질 리뷰를 수행합니다.

## Task 3: 설치와 운영 연결

Files: scripts/relay 설치/운영 도구, docs/oliveyoung-free-relay.md, 개인 scratch의 비밀 파일.

- [ ] 전용 표준 계정에 검토 가능한 설치 스크립트를 준비합니다. 관리자 인증과 GUI 로그인은 사용자에게 필요한 시점에 요청합니다.
- [ ] 고정 Tunnel/DNS와 Access Service Auth 정책을 구성합니다. 생성 식별자와 복구 절차를 기록합니다. 비밀을 stdout에 출력하지 않습니다.
- [ ] launchd singleton·재시작 간격·로그 크기 관리·소유 프로세스 회수 검사를 설치하고 로컬 반복 smoke를 수행합니다.
- [ ] Worker secrets 연결 후 Access 거절·인증 통과·실제 MCP 조회를 확인합니다.

## Task 4: 통합과 기록

- [ ] `npm run check`, `npm run test:coverage` 100%, `npm run build`, `npm audit`를 확인합니다.
- [ ] 리뷰된 커밋을 PR로 올리고 CI·Coverage·CodeQL을 확인한 뒤 승인 범위 안에서 병합·배포합니다.
- [ ] 배포 결과, RSS·탭·프로세스 회수 실측과 미완료 조건을 개인 PROJECT.md에 기록합니다.
23 changes: 23 additions & 0 deletions docs/superpowers/specs/2026-09-15-relay-operations-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 올리브영 Mac 중계 운영 설계

사용자는 전용 계정·브라우저·조회 서버·Cloudflare Tunnel·Access 구성을 승인했으며 메모리와 탭 회수 실패 방지를 추가로 요구했습니다.

## 실행 경계

기존 Worker는 Access 서비스 토큰과 중계 Bearer 토큰으로 고정된 중계 주소를 호출합니다. localhost:4319만 Tunnel에 연결합니다. 임의 URL, 브라우저 디버깅 포트, SSH, 개인 파일은 터널 대상에 넣지 않습니다. 중계는 전용 표준 macOS 사용자로 실행합니다. 계정 생성·GUI 로그인에 필요한 관리자 인증은 사용자가 직접 수행하며 비밀번호를 요청하거나 기록하지 않습니다.

## 자원 수명

브라우저 1개·컨텍스트 1개·기본 페이지 1개를 유지합니다. 요청별 새 탭을 만들지 않습니다. 예기치 않은 팝업은 닫고 닫기 실패 시 세션을 폐기합니다. 브라우저 종료를 확인하기 전에는 후속 브라우저를 실행하지 않습니다. launch 실패·요청 timeout·페이지 crash·SIGTERM·재시작에도 소유한 프로세스만 회수합니다. 개인 브라우저를 이름으로 일괄 종료하지 않습니다.

세션은 최대 30분 또는 200회 조회 후 유휴 경계에서 교체합니다. 15초 요청 제한에 더해 Node 측 watchdog으로 브라우저 이벤트 루프 정지도 감지합니다. 종료 timeout에는 소유 프로세스에만 강제 종료를 적용하고 확인이 불가능하면 fail-closed로 중지합니다. 30초 주기의 전체 소유 프로세스 RSS 점검으로 1GiB 초과 시 회수합니다. Node에도 256MiB heap 상한을 둡니다. 이 값은 운영 초깃값이며 무누수를 보증하는 수치가 아닙니다.

## 입력·부하·비밀

기존 고정 네 API·필드 타입·16KiB 검증을 유지합니다. 실행·대기 합계 8개, 직렬 실행을 유지합니다. 중계 전체에 분당 30회·UTC 하루 3,000회 상한을 적용하고 파일에 원자적으로 저장해 재시작으로 초기화되지 않게 합니다. 저장 실패·손상은 요청 거절로 처리합니다. 큐 슬롯은 본문 읽기 전 확보하여 느린 업로드도 제한합니다. 원본 JSON 응답은 최대 2MiB로 제한합니다.

토큰과 사용자 검색어·좌표·원본 응답은 로그에 남기지 않습니다. 운영 로그는 상태·건수·RSS·탭 수·재시작 사유만 기록하며 크기를 제한합니다. 토큰은 Worker secrets와 전용 계정 읽기 권한의 파일에 보관합니다.

## 배포·검증

새 기능은 TDD·100% coverage·check·build·audit와 독립 리뷰를 거칩니다. 실제 브라우저에서 반복 조회, 강제 팝업, timeout, 순차 교체, SIGTERM 및 재기동을 검증하고 브라우저/탭/프로세스 수와 RSS를 기록합니다. Access 무인증 차단, 잘못된 Bearer 차단, MCP 실제 조회까지 확인합니다. GUI 계정 준비 전에는 개인 계정으로 상시 공개 중계를 대신 실행하지 않습니다.
26 changes: 26 additions & 0 deletions scripts/relay/browser-environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/** 브라우저에는 운영 토큰 대신 GUI 실행에 필요한 환경만 전달합니다. */
export function browserEnvironment(
env: NodeJS.ProcessEnv,
crashDir: string,
): Record<string, string> {
const result: Record<string, string> = { BREAKPAD_DUMP_LOCATION: crashDir };
for (const name of [
'HOME',
'PATH',
'TMPDIR',
'LANG',
'LC_ALL',
'LC_CTYPE',
'LC_MESSAGES',
'LC_COLLATE',
'LC_NUMERIC',
'LC_TIME',
'LC_MONETARY',
'DISPLAY',
'XAUTHORITY',
'__CF_USER_TEXT_ENCODING',
]) {
if (env[name] !== undefined) result[name] = env[name];
}
return result;
}
53 changes: 41 additions & 12 deletions scripts/relay/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,47 @@ import type { Page } from 'playwright';
import type { BrowserRunner } from './oliveyoung.js';
export function createBrowserRunner(page: Pick<Page, 'evaluate'>): BrowserRunner {
return async (path, body) => {
const result = await page.evaluate(async ({ path, body, timeout }) => {
const response = await fetch(`https://www.oliveyoung.co.kr${path}`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify(body), signal: AbortSignal.timeout(timeout),
});
const text = await response.text();
let parsed = null;
try { parsed = JSON.parse(text); } catch { /* 보안 검증 HTML을 정상 데이터로 반환하지 않습니다. */ }
return { status: response.status, body: parsed };
}, { path, body, timeout: 15000 });
if (result.status !== 200 || result.body?.status !== 'SUCCESS') throw new Error('올리브영 브라우저 응답 실패');
const result = await page.evaluate(
async ({ path, body, timeout }) => {
const response = await fetch(`https://www.oliveyoung.co.kr${path}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout),
});
const reader = response.body?.getReader();
if (!reader) throw new Error('Browser response body missing');
const decoder = new TextDecoder();
let bytes = 0;
let text = '';
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > 2 * 1024 * 1024) {
await reader.cancel();
throw new Error('Browser response size limit');
}
text += decoder.decode(chunk.value, { stream: true });
}
text += decoder.decode();
let parsed = null;
try {
parsed = JSON.parse(text);
} catch {
/* 보안 검증 HTML을 정상 데이터로 반환하지 않습니다. */
}
return { status: response.status, body: parsed };
},
{ path, body, timeout: 15000 },
);
if (result.status !== 200 || result.body?.status !== 'SUCCESS')
throw new Error('올리브영 브라우저 응답 실패');
return result.body;
};
}
24 changes: 24 additions & 0 deletions scripts/relay/guard-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** 부모의 IPC 단절과 정상 종료를 같은 회수 경로로 처리합니다. */
export function createGuardController<T extends { close(): Promise<void> }>(
launch: () => Promise<T>,
release: () => Promise<void>,
) {
let startup: Promise<T> | undefined;
let shutdown: Promise<void> | undefined;
return {
start() {
startup ??= launch();
return startup;
},
stop() {
shutdown ??= (async () => {
if (startup) {
const owned = await startup;
await owned.close();
}
await release();
})();
return shutdown;
},
};
}
Loading